diff --git a/.gemini/commands/github/cleanup-back-to-main.toml b/.gemini/commands/github/cleanup-back-to-main.toml new file mode 100644 index 0000000000000000000000000000000000000000..957eed06f20db67132a202c35b43645101616a03 --- /dev/null +++ b/.gemini/commands/github/cleanup-back-to-main.toml @@ -0,0 +1,13 @@ +description = "Go back to main and clean up the branch." + +prompt = """ +I'm done with the work on this branch, and I'm ready to go back to main and clean up. + +Here is the workflow I'd like you to follow: + +1. **Get Current Branch:** First, I need you to get the name of the current branch and save it. +2. **Branch Check:** Check if the current branch is `main`. If it is, I need you to stop and let me know. +3. **Go to Main:** Next, I need you to checkout the main branch. +4. **Pull Latest:** Once you are on the main branch, I need you to pull down the latest changes to make sure I'm up to date. +5. **Branch Cleanup:** Finally, I need you to delete the branch that you noted in the first step. +""" diff --git a/.gemini/commands/oncall/pr-review.toml b/.gemini/commands/oncall/pr-review.toml new file mode 100644 index 0000000000000000000000000000000000000000..88df74c5861a954a1dc48fb2d0fc5c5b83547421 --- /dev/null +++ b/.gemini/commands/oncall/pr-review.toml @@ -0,0 +1,47 @@ +description = "Review a specific pull request" + +prompt = """ +## Mission: Comprehensive Pull Request Review + +Today, our mission is to meticulously review community pull requests (PRs) for this project. We will proceed systematically, evaluating each candidate PR for its quality, adherence to standards, and readiness for merging. + +### Workflow: + +1. **PR Preparation & Initial Assessment**: + * **You will check out the designated PR {{args}}** into a temporary branch. + * **Execute the preflight checks (`npm run preflight`)**. This includes building, linting, and running all unit tests. + * Analyze the output of these preflight checks, noting any failures, warnings, or linting issues. + +2. **In-Depth Code Review**: + * **Your primary role is to conduct a thorough and in-depth code review** of the changes introduced in the PR. Focus your analysis on the following criteria: + * **Correctness**: Does the code achieve its stated purpose without bugs or logical errors? + * **Maintainability**: Is the code clean, well-structured, and easy to understand and modify in the future? Consider factors like code clarity, modularity, and adherence to established design patterns. + * **Readability**: Is the code well-commented (where necessary) and consistently formatted according to our project's coding style guidelines? + * **Efficiency**: Are there any obvious performance bottlenecks or resource inefficiencies introduced by the changes? + * **Security**: Are there any potential security vulnerabilities or insecure coding practices? + * **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors? + * **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness. + * Based on your analysis, you will determine if the PR is **safe to merge**. + +3. **Reviewing Previous Feedback**: + * **Access and examine the PR's history** to identify any **outstanding requests or unresolved comments from previous reviews**. Incorporate these into your current review and explicitly highlight if they have been adequately addressed in the current state of the PR. + +4. **Decision and Output Generation**: + * **If the PR is deemed safe to merge** (after your comprehensive review and considering previous feedback): + * Draft a **friendly, concise, and professional approval message**. + * **The approval message should:** + * Clearly state that the PR is approved. + * Briefly acknowledge the quality or value of the contribution (e.g., "Great work on X feature!" or "Appreciate the fix for Y issue!"). + * **Do NOT mention the preflight checks or unit testing**, as these are internal processes. + * Be suitable for public display on GitHub. + * **If the PR is NOT safe to merge**: + * Provide a **clear, constructive, and detailed summary of the issues found**. + * Suggest **specific actionable changes** required for the PR to become merge-ready. + * Ensure the feedback is professional and encourages the contributor. + +### Post-PR Action: + +* After providing your review and decision for the current PR, I will wait for you to perform any manual testing you wish to do. Please let me know when you are finished. +* Once you have confirmed that you are done, I will switch to the `main` branch, clean up the local branch, and perform a pull to ensure we are synchronized with the latest upstream changes for the next review. + +""" diff --git a/.gemini/skills/async-pr-review/scripts/async-review.sh b/.gemini/skills/async-pr-review/scripts/async-review.sh new file mode 100644 index 0000000000000000000000000000000000000000..9977a6f907d59fc56541323656efefdf1ad83711 --- /dev/null +++ b/.gemini/skills/async-pr-review/scripts/async-review.sh @@ -0,0 +1,245 @@ +#!/bin/bash + +notify() { + local title="${1}" + local message="${2}" + local pr="${3}" + # Terminal escape sequence + printf "\e]9;%s | PR #%s | %s\a" "${title}" "${pr}" "${message}" + # Native macOS notification + os_type="$(uname || true)" + if [[ "${os_type}" == "Darwin" ]]; then + osascript -e "display notification \"${message}\" with title \"${title}\" subtitle \"PR #${pr}\"" + fi +} + +pr_number="${1}" +if [[ -z "${pr_number}" ]]; then + echo "Usage: async-review " + exit 1 +fi + +base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "${base_dir}" ]]; then + echo "❌ Must be run from within a git repository." + exit 1 +fi + +# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs +pr_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}" +target_dir="${pr_dir}/worktree" +log_dir="${pr_dir}/logs" + +cd "${base_dir}" || exit 1 + +mkdir -p "${log_dir}" +rm -f "${log_dir}/setup.exit" "${log_dir}/final-assessment.exit" "${log_dir}/final-assessment.md" + +echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "${log_dir}/setup.log" +git worktree remove -f "${target_dir}" >> "${log_dir}/setup.log" 2>&1 || true +git branch -D "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1 || true +git worktree prune >> "${log_dir}/setup.log" 2>&1 || true + +echo "πŸ“‘ Fetching PR #${pr_number}..." | tee -a "${log_dir}/setup.log" +if ! git fetch origin -f "pull/${pr_number}/head:gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then + echo 1 > "${log_dir}/setup.exit" + echo "❌ Fetch failed. Check ${log_dir}/setup.log" + notify "Async Review Failed" "Fetch failed." "${pr_number}" + exit 1 +fi + +if [[ ! -d "${target_dir}" ]]; then + echo "🧹 Pruning missing worktrees..." | tee -a "${log_dir}/setup.log" + git worktree prune >> "${log_dir}/setup.log" 2>&1 + echo "🌿 Creating worktree in ${target_dir}..." | tee -a "${log_dir}/setup.log" + if ! git worktree add "${target_dir}" "gemini-async-pr-${pr_number}" >> "${log_dir}/setup.log" 2>&1; then + echo 1 > "${log_dir}/setup.exit" + echo "❌ Worktree creation failed. Check ${log_dir}/setup.log" + notify "Async Review Failed" "Worktree creation failed." "${pr_number}" + exit 1 + fi +else + echo "🌿 Worktree already exists." | tee -a "${log_dir}/setup.log" +fi +echo 0 > "${log_dir}/setup.exit" + +cd "${target_dir}" || exit 1 + +echo "πŸš€ Launching background tasks. Logs saving to: ${log_dir}" + +echo " ↳ [1/5] Grabbing PR diff..." +rm -f "${log_dir}/pr-diff.exit" +{ gh pr diff "${pr_number}" > "${log_dir}/pr-diff.diff" 2>&1; echo $? > "${log_dir}/pr-diff.exit"; } & + +echo " ↳ [2/5] Starting build and lint..." +rm -f "${log_dir}/build-and-lint.exit" +{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "${log_dir}/build-and-lint.log" 2>&1; echo $? > "${log_dir}/build-and-lint.exit"; } & + +# Dynamically resolve gemini binary (fallback to your nightly path) +GEMINI_CMD="$(command -v gemini || echo "${HOME}/.gcli/nightly/node_modules/.bin/gemini")" +# shellcheck disable=SC2312 +POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml" + +echo " ↳ [3/5] Starting Gemini code review..." +rm -f "${log_dir}/review.exit" +{ "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "/review-frontend ${pr_number}" > "${log_dir}/review.md" 2>&1; echo $? > "${log_dir}/review.exit"; } & + +echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..." +rm -f "${log_dir}/npm-test.exit" +{ + while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done + read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit="" + if [[ "${build_exit}" == "0" ]]; then + gh pr checks "${pr_number}" > "${log_dir}/ci-checks.log" 2>&1 + ci_status=$? + + if [[ "${ci_status}" -eq 0 ]]; then + echo "CI checks passed. Skipping local npm tests." > "${log_dir}/npm-test.log" + echo 0 > "${log_dir}/npm-test.exit" + elif [[ "${ci_status}" -eq 8 ]]; then + echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "${log_dir}/npm-test.log" + echo 0 > "${log_dir}/npm-test.exit" + else + echo "CI checks failed. Failing checks:" > "${log_dir}/npm-test.log" + gh pr checks "${pr_number}" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "${log_dir}/npm-test.log" 2>&1 + + echo "Attempting to extract failing test files from CI logs..." >> "${log_dir}/npm-test.log" + pr_branch="$(gh pr view "${pr_number}" --json headRefName -q '.headRefName' 2>/dev/null || true)" + run_id="$(gh run list --branch "${pr_branch}" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null || true)" + + failed_files="" + if [[ -n "${run_id}" ]]; then + failed_files="$(gh run view "${run_id}" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq || true)" + fi + + if [[ -n "${failed_files}" ]]; then + echo "Found failing test files from CI:" >> "${log_dir}/npm-test.log" + for f in ${failed_files}; do echo " - ${f}" >> "${log_dir}/npm-test.log"; done + echo "Running ONLY failing tests locally..." >> "${log_dir}/npm-test.log" + + exit_code=0 + for file in ${failed_files}; do + if [[ "${file}" == packages/* ]]; then + ws_dir="$(echo "${file}" | cut -d'/' -f1,2)" + else + ws_dir="$(echo "${file}" | cut -d'/' -f1)" + fi + rel_file="${file#"${ws_dir}"/}" + + echo "--- Running ${rel_file} in workspace ${ws_dir} ---" >> "${log_dir}/npm-test.log" + if ! npm run test:ci -w "${ws_dir}" -- "${rel_file}" >> "${log_dir}/npm-test.log" 2>&1; then + exit_code=1 + fi + done + echo "${exit_code}" > "${log_dir}/npm-test.exit" + else + echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "${log_dir}/npm-test.log" + echo 1 > "${log_dir}/npm-test.exit" + fi + fi + else + echo "Skipped due to build-and-lint failure" > "${log_dir}/npm-test.log" + echo 1 > "${log_dir}/npm-test.exit" + fi +} & + +echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..." +rm -f "${log_dir}/test-execution.exit" +{ + while [[ ! -f "${log_dir}/build-and-lint.exit" ]]; do sleep 1; done + read -r build_exit < "${log_dir}/build-and-lint.exit" || build_exit="" + if [[ "${build_exit}" == "0" ]]; then + "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Analyze the diff for PR ${pr_number} using 'gh pr diff ${pr_number}'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "${log_dir}/test-execution.log" 2>&1; echo $? > "${log_dir}/test-execution.exit" + else + echo "Skipped due to build-and-lint failure" > "${log_dir}/test-execution.log" + echo 1 > "${log_dir}/test-execution.exit" + fi +} & + +echo "βœ… All tasks dispatched!" +echo "You can monitor progress with: tail -f ${log_dir}/*.log" +echo "Read your review later at: ${log_dir}/review.md" + +# Polling loop to wait for all background tasks to finish +tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution") +log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log") + +declare -A task_done +for t in "${tasks[@]}"; do task_done[${t}]=0; done + +all_done=0 +while [[ "${all_done}" -eq 0 ]]; do + clear + echo "==================================================" + echo "πŸš€ Async PR Review Status for PR #${pr_number}" + echo "==================================================" + echo "" + + all_done=1 + for i in "${!tasks[@]}"; do + t="${tasks[${i}]}" + + if [[ -f "${log_dir}/${t}.exit" ]]; then + read -r task_exit < "${log_dir}/${t}.exit" || task_exit="" + if [[ "${task_exit}" == "0" ]]; then + echo " βœ… ${t}: SUCCESS" + else + echo " ❌ ${t}: FAILED (exit code ${task_exit})" + fi + task_done[${t}]=1 + else + echo " ⏳ ${t}: RUNNING" + all_done=0 + fi + done + + echo "" + echo "==================================================" + echo "πŸ“ Live Logs (Last 5 lines of running tasks)" + echo "==================================================" + + for i in "${!tasks[@]}"; do + t="${tasks[${i}]}" + log_file="${log_files[${i}]}" + + if [[ "${task_done[${t}]}" -eq 0 ]]; then + if [[ -f "${log_dir}/${log_file}" ]]; then + echo "" + echo "--- ${t} ---" + tail -n 5 "${log_dir}/${log_file}" + fi + fi + done + + if [[ "${all_done}" -eq 0 ]]; then + sleep 3 + fi +done + +clear +echo "==================================================" +echo "πŸš€ Async PR Review Status for PR #${pr_number}" +echo "==================================================" +echo "" +for t in "${tasks[@]}"; do + read -r task_exit < "${log_dir}/${t}.exit" || task_exit="" + if [[ "${task_exit}" == "0" ]]; then + echo " βœ… ${t}: SUCCESS" + else + echo " ❌ ${t}: FAILED (exit code ${task_exit})" + fi +done +echo "" + +echo "⏳ Tasks complete! Synthesizing final assessment..." +if ! "${GEMINI_CMD}" --policy "${POLICY_PATH}" -p "Read the review at ${log_dir}/review.md, the automated test logs at ${log_dir}/npm-test.log, and the manual test execution logs at ${log_dir}/test-execution.log. Summarize the results, state whether the build and tests passed based on ${log_dir}/build-and-lint.exit and ${log_dir}/npm-test.exit, and give a final recommendation for PR ${pr_number}." > "${log_dir}/final-assessment.md" 2>&1; then + echo $? > "${log_dir}/final-assessment.exit" + echo "❌ Final assessment synthesis failed!" + echo "Check ${log_dir}/final-assessment.md for details." + notify "Async Review Failed" "Final assessment synthesis failed." "${pr_number}" + exit 1 +fi + +echo 0 > "${log_dir}/final-assessment.exit" +echo "βœ… Final assessment complete! Check ${log_dir}/final-assessment.md" +notify "Async Review Complete" "Review and test execution finished successfully." "${pr_number}" diff --git a/.gemini/skills/async-pr-review/scripts/check-async-review.sh b/.gemini/skills/async-pr-review/scripts/check-async-review.sh new file mode 100644 index 0000000000000000000000000000000000000000..846f703237997dfe7150ee5a8995544f230c3203 --- /dev/null +++ b/.gemini/skills/async-pr-review/scripts/check-async-review.sh @@ -0,0 +1,65 @@ +#!/bin/bash +pr_number="${1}" + +if [[ -z "${pr_number}" ]]; then + echo "Usage: check-async-review " + exit 1 +fi + +base_dir="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "${base_dir}" ]]; then + echo "❌ Must be run from within a git repository." + exit 1 +fi + +log_dir="${base_dir}/.gemini/tmp/async-reviews/pr-${pr_number}/logs" + +if [[ ! -d "${log_dir}" ]]; then + echo "STATUS: NOT_FOUND" + echo "❌ No logs found for PR #${pr_number} in ${log_dir}" + exit 0 +fi + +tasks=( + "setup|setup.log" + "pr-diff|pr-diff.diff" + "build-and-lint|build-and-lint.log" + "review|review.md" + "npm-test|npm-test.log" + "test-execution|test-execution.log" + "final-assessment|final-assessment.md" +) + +all_done=true +echo "STATUS: CHECKING" + +for task_info in "${tasks[@]}"; do + IFS="|" read -r task_name log_file <<< "${task_info}" + + file_path="${log_dir}/${log_file}" + exit_file="${log_dir}/${task_name}.exit" + + if [[ -f "${exit_file}" ]]; then + read -r exit_code < "${exit_file}" || exit_code="" + if [[ "${exit_code}" == "0" ]]; then + echo "βœ… ${task_name}: SUCCESS" + else + echo "❌ ${task_name}: FAILED (exit code ${exit_code})" + echo " Last lines of ${file_path}:" + tail -n 3 "${file_path}" | sed 's/^/ /' || true + fi + elif [[ -f "${file_path}" ]]; then + echo "⏳ ${task_name}: RUNNING" + all_done=false + else + echo "βž– ${task_name}: NOT STARTED" + all_done=false + fi +done + +if [[ "${all_done}" == "true" ]]; then + echo "STATUS: COMPLETE" + echo "LOG_DIR: ${log_dir}" +else + echo "STATUS: IN_PROGRESS" +fi diff --git a/docs/admin/enterprise-controls.md b/docs/admin/enterprise-controls.md new file mode 100644 index 0000000000000000000000000000000000000000..575b597db644839f520c07dafb2493ba53423fad --- /dev/null +++ b/docs/admin/enterprise-controls.md @@ -0,0 +1,176 @@ +# Enterprise Admin Controls + +Gemini CLI empowers enterprise administrators to manage and enforce security +policies and configuration settings across their entire organization. Secure +defaults are enabled automatically for all enterprise users, but can be +customized via the [Management Console](https://goo.gle/manage-gemini-cli). + +**Enterprise Admin Controls are enforced globally and cannot be overridden by +users locally**, ensuring a consistent security posture. + +## Admin Controls vs. System Settings + +While [System-wide settings](../cli/settings.md) act as convenient configuration +overrides, they can still be modified by users with sufficient privileges. In +contrast, admin controls are immutable at the local level, making them the +preferred method for enforcing policy. + +## Available Controls + +### Strict Mode + +**Enabled/Disabled** | Default: enabled + +If enabled, users will not be able to enter yolo mode. + +### Extensions + +**Enabled/Disabled** | Default: disabled + +If disabled, users will not be able to use or install extensions. See +[Extensions](../extensions/index.md) for more details. + +### MCP + +#### Enabled/Disabled + +**Enabled/Disabled** | Default: disabled + +If disabled, users will not be able to use MCP servers. See +[MCP Server Integration](../tools/mcp-server.md) for more details. + +#### MCP Servers (preview) + +**Default**: empty + +Allows administrators to define an explicit allowlist of MCP servers. This +guarantees that users can only connect to trusted MCP servers defined by the +organization. + +**Allowlist Format:** + +```json +{ + "mcpServers": { + "external-provider": { + "url": "https://api.mcp-provider.com", + "type": "sse", + "trust": true, + "includeTools": ["toolA", "toolB"], + "excludeTools": [] + }, + "internal-corp-tool": { + "url": "https://mcp.internal-tool.corp", + "type": "http", + "includeTools": [], + "excludeTools": ["adminTool"] + } + } +} +``` + +**Supported Fields:** + +- `url`: (Required) The full URL of the MCP server endpoint. +- `type`: (Required) The connection type (for example, `sse` or `http`). +- `trust`: (Optional) If set to `true`, the server is trusted and tool execution + will not require user approval. +- `includeTools`: (Optional) An explicit list of tool names to allow. If + specified, only these tools will be available. +- `excludeTools`: (Optional) A list of tool names to hide. These tools will be + blocked. + +**Client Enforcement Logic:** + +- **Empty Allowlist**: If the admin allowlist is empty, the client uses the + user’s local configuration as is (unless the MCP toggle above is disabled). +- **Active Allowlist**: If the allowlist contains one or more servers, **all + locally configured servers not present in the allowlist are ignored**. +- **Configuration Merging**: For a server to be active, it must exist in + **both** the admin allowlist and the user’s local configuration (matched by + name). The client merges these definitions as follows: + - **Override Fields**: The `url`, `type`, & `trust` are always taken from the + admin allowlist, overriding any local values. + - **Tools Filtering**: If `includeTools` or `excludeTools` are defined in the + allowlist, the admin’s rules are used exclusively. If both are undefined in + the admin allowlist, the client falls back to the user’s local tool + settings. + - **Cleared Fields**: To ensure security and consistency, the client + automatically clears local execution fields (`command`, `args`, `env`, + `cwd`, `httpUrl`, `tcp`). This prevents users from overriding the connection + method. + - **Other Fields**: All other MCP fields are pulled from the user’s local + configuration. +- **Missing Allowlisted Servers**: If a server appears in the admin allowlist + but is missing from the local configuration, it will not be initialized. This + ensures users maintain final control over which permitted servers are actually + active in their environment. + +#### Required MCP Servers (preview) + +**Default**: empty + +Allows administrators to define MCP servers that are **always injected** into +the user's environment. Unlike the allowlist (which filters user-configured +servers), required servers are automatically added regardless of the user's +local configuration. + +**Required Servers Format:** + +```json +{ + "requiredMcpServers": { + "corp-compliance-tool": { + "url": "https://mcp.corp/compliance", + "type": "http", + "trust": true, + "description": "Corporate compliance tool" + }, + "internal-registry": { + "url": "https://registry.corp/mcp", + "type": "sse", + "authProviderType": "google_credentials", + "oauth": { + "scopes": ["https://www.googleapis.com/auth/scope"] + } + } + } +} +``` + +**Supported Fields:** + +- `url`: (Required) The full URL of the MCP server endpoint. +- `type`: (Required) The connection type (`sse` or `http`). +- `trust`: (Optional) If set to `true`, tool execution will not require user + approval. Defaults to `true` for required servers. +- `description`: (Optional) Human-readable description of the server. +- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`, + `google_credentials`, or `service_account_impersonation`). +- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and + `clientSecret`. +- `targetAudience`: (Optional) OAuth target audience for service-to-service + auth. +- `targetServiceAccount`: (Optional) Service account email to impersonate. +- `headers`: (Optional) Additional HTTP headers to send with requests. +- `includeTools` / `excludeTools`: (Optional) Tool filtering lists. +- `timeout`: (Optional) Timeout in milliseconds for MCP requests. + +**Client Enforcement Logic:** + +- Required servers are injected **after** allowlist filtering, so they are + always available even if the allowlist is active. +- If a required server has the **same name** as a locally configured server, the + admin configuration **completely overrides** the local one. +- Required servers only support remote transports (`sse`, `http`). Local + execution fields (`command`, `args`, `env`, `cwd`) are not supported. +- Required servers can coexist with allowlisted servers β€” both features work + independently. + +### Unmanaged Capabilities + +**Enabled/Disabled** | Default: disabled + +If disabled, users will not be able to use certain features. Currently, this +control disables Agent Skills. See [Agent Skills](../cli/skills.md) for more +details. diff --git a/docs/assets/gemini-screenshot.png b/docs/assets/gemini-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..c01777af0f2b85419b884cd611ee55474c5ba4f8 Binary files /dev/null and b/docs/assets/gemini-screenshot.png differ diff --git a/docs/assets/monitoring-dashboard-metrics.png b/docs/assets/monitoring-dashboard-metrics.png new file mode 100644 index 0000000000000000000000000000000000000000..7a4a1e221ec329623d9d34befd138161cf95d95b Binary files /dev/null and b/docs/assets/monitoring-dashboard-metrics.png differ diff --git a/docs/assets/monitoring-dashboard-overview.png b/docs/assets/monitoring-dashboard-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..d84f214766775c6a1ceb63177abd47dda5956d7c Binary files /dev/null and b/docs/assets/monitoring-dashboard-overview.png differ diff --git a/docs/assets/theme-tokyonight-dark.png b/docs/assets/theme-tokyonight-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ebeec9354820a30d0c60fd81c1041d68fe633523 Binary files /dev/null and b/docs/assets/theme-tokyonight-dark.png differ diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md new file mode 100644 index 0000000000000000000000000000000000000000..f10373559cc715e6429518c456d8e79c301aee0e --- /dev/null +++ b/docs/changelogs/index.md @@ -0,0 +1,1214 @@ +# Gemini CLI release notes + +Gemini CLI has three major release channels: nightly, preview, and stable. For +most users, we recommend the stable release. + +On this page, you can find information regarding the current releases and +announcements from each release. + +For the full changelog, refer to +[Releases - google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli/releases) +on GitHub. + +## Current releases + +| Release channel | Notes | +| :-------------------- | :---------------------------------------------- | +| Nightly | Nightly release with the most recent changes. | +| [Preview](preview.md) | Experimental features ready for early feedback. | +| [Stable](latest.md) | Stable, recommended for general use. | + +## Announcements: v0.60.0 - 2026-09-15 + +- **Extension & Tool Safety Hardening:** Prompt for user consent on environment + changes and sanitize runtime-altering environment variables, while enforcing + envelope metadata provenance for untrusted tool outputs + ([#28863](https://github.com/google-gemini/gemini-cli/pull/28863) by + @amelidev, [#29215](https://github.com/google-gemini/gemini-cli/pull/29215) by + @luisfelipe-alt). +- **Sandbox Directory Isolation:** Isolated settings and temporary directories + inside sandbox containers, including the macOS Seatbelt sandbox + ([#29171](https://github.com/google-gemini/gemini-cli/pull/29171), + [#29216](https://github.com/google-gemini/gemini-cli/pull/29216) by + @jvargassanchez-dot). + +## Announcements: v0.59.0 - 2026-09-08 + +- **MCP OAuth SSRF Mitigation:** Prevented Server-Side Request Forgery (SSRF) + during Model Context Protocol (MCP) OAuth metadata discovery and + authentication + ([#29081](https://github.com/google-gemini/gemini-cli/pull/29081) by + @josebalius). +- **Fail-Closed Workspace Trust:** Enforced a secure, fail-closed workspace + trust policy and filtered available `mcpServers` when operating in restricted + mode ([#29099](https://github.com/google-gemini/gemini-cli/pull/29099) by + @luisfelipe-alt). + +## Announcements: v0.58.0 - 2026-09-01 + +- **Core Security & Path Handling:** Enforced consistent symlink evaluation in + ignore path handling and declared top-level safety checkers in write policy + configuration + ([#28915](https://github.com/google-gemini/gemini-cli/pull/28915), + [#28961](https://github.com/google-gemini/gemini-cli/pull/28961) by + @luisfelipe-alt). +- **macOS Seatbelt Sandbox:** Isolated Docker and container runtime sockets and + binaries to restrict container runtime access in the macOS sandbox + ([#28935](https://github.com/google-gemini/gemini-cli/pull/28935) by + @josebalius). +- **Session & Recovery Optimizations:** Optimized history rollback mechanics, + retry nudge prompts, and resolved stale cancellation errors in the + Agent-to-Agent server + ([#28934](https://github.com/google-gemini/gemini-cli/pull/28934) by + @DavidAPierce, + [#28940](https://github.com/google-gemini/gemini-cli/pull/28940) by + @amelidev). + +## Announcements: v0.54.0 - 2026-08-06 + +- **PR Automation & Antigravity Agent:** Integrated the Antigravity agent runner + with dual-locking Firestore concurrency controls to secure the PR generator + ([#28434](https://github.com/google-gemini/gemini-cli/pull/28434), + [#28432](https://github.com/google-gemini/gemini-cli/pull/28432) by + @joneba-google). +- **Caretaker Triaging & Security:** Enhanced caretaker triage to post comments + prior to auto-closing issues and sanitized issue titles under untrusted + context ([#28411](https://github.com/google-gemini/gemini-cli/pull/28411), + [#28352](https://github.com/google-gemini/gemini-cli/pull/28352) by @chadd28). +- **Security and Session Robustness:** Prevented cleartext credential leakage by + enforcing HTTPS, rotated session IDs on model fallbacks, and skipped merged + function-response turns in active loops + ([#28517](https://github.com/google-gemini/gemini-cli/pull/28517) by + @amelidev, [#28565](https://github.com/google-gemini/gemini-cli/pull/28565) by + @adamfweidman). + +## Announcements: v0.53.0 - 2026-07-28 + +- **Caretaker Triage Orchestration:** Implemented an LLM triage orchestrator and + container build setup + ([#28345](https://github.com/google-gemini/gemini-cli/pull/28345) by + @chadd28). +- **Eval Coverage Reporting:** Introduced a new command for generating + evaluation coverage reports + ([#28169](https://github.com/google-gemini/gemini-cli/pull/28169) by @ved015). +- **Security & Loop Mitigations:** Enforced workspace trust and task isolation + in the A2A server, aligned macOS Seatbelt profiles with the deny-default + model, and mitigated infinite ReAct/prompt injection loops + ([#28470](https://github.com/google-gemini/gemini-cli/pull/28470) by + @luisfelipe-alt, + [#28424](https://github.com/google-gemini/gemini-cli/pull/28424) by + @ompatel-aiml). + +## Announcements: v0.52.0 - 2026-07-22 + +- **Caretaker Triage & Egress Services:** Implemented the core triage worker + foundational modules, main worker execution loops, and egress action + publishers alongside the octokit GitHub Action handler for egress services + ([#28163](https://github.com/google-gemini/gemini-cli/pull/28163), + [#28306](https://github.com/google-gemini/gemini-cli/pull/28306) by @chadd28). +- **Core Tool Enhancements:** Bypassed LLM correction for JSON and IPYNB files + in `write_file` and `replace` tools, and simplified plan mode write policy to + support relative paths + ([#28223](https://github.com/google-gemini/gemini-cli/pull/28223) by + @amelidev, [#28398](https://github.com/google-gemini/gemini-cli/pull/28398) by + @DavidAPierce). +- **Auth & Privacy Improvements:** Displayed clear error messages when user + account has no Code Assist tier, and bumped `google-auth-library` to version + 10.9.0 ([#28304](https://github.com/google-gemini/gemini-cli/pull/28304) by + @ompatel-aiml, + [#28385](https://github.com/google-gemini/gemini-cli/pull/28385) by + @jerrylin3321). + +## Announcements: v0.50.0 - 2026-07-08 + +- **Tool Registry Discovery:** Introduced tool registry discovery capabilities + to automatically detect and register available tools + ([#28113](https://github.com/google-gemini/gemini-cli/pull/28113) by @ved015). +- **Release Verification & CI Stability:** Enhanced release verification by + ignoring scripts during verification, preventing workspace binary shadowing, + and safeguarding against bad NPM releases + ([#28116](https://github.com/google-gemini/gemini-cli/pull/28116) by + @rmedranollamas, + [#28132](https://github.com/google-gemini/gemini-cli/pull/28132) by + @galdawave). + +## Announcements: v0.45.0 - 2026-06-03 + +- **Context Simplification:** Completed major architectural work to simplify the + `ContextManager`, improving system robustness and performance + ([#27345](https://github.com/google-gemini/gemini-cli/pull/27345) by + @joshualitt). +- **A2A Usage Metadata:** Exposed critical usage metadata in the Agent-to-Agent + (A2A) protocol for better resource tracking + ([#27288](https://github.com/google-gemini/gemini-cli/pull/27288) by + @jvargassanchez-dot). +- **Reliability Fixes:** Addressed Termux relaunch loops, PTY resize errors, and + forced sequential execution for topic updates + ([#27110](https://github.com/google-gemini/gemini-cli/pull/27110) by @saymanq, + [#27357](https://github.com/google-gemini/gemini-cli/pull/27357) by + @jvargassanchez-dot, + [#27461](https://github.com/google-gemini/gemini-cli/pull/27461) by + @scidomino). + +## Announcements: v0.44.0 - 2026-05-27 + +- **Unified Auto Mode:** Streamlined the automation experience by merging + specialized Auto modes into a single, unified mode + ([#26714](https://github.com/google-gemini/gemini-cli/pull/26714) by + @DavidAPierce). +- **New Editor Integrations:** Added native support for Sublime Text and Emacs + Client ([#21090](https://github.com/google-gemini/gemini-cli/pull/21090) by + @alberti42). +- **Enhanced TUI Testing:** Introduced `agent-tui` and `tui-tester` skills for + programmatic testing and automation of terminal UI applications + ([#27121](https://github.com/google-gemini/gemini-cli/pull/27121) by + @adamfweidman). + +## Announcements: v0.43.0 - 2026-05-22 + +- **Surgical Code Edits:** Steered Gemini models to prefer the `edit` tool for + surgical modifications, improving speed and precision + ([#26480](https://github.com/google-gemini/gemini-cli/pull/26480) by + @aishaneeshah). +- **Session Export and Import:** Added the ability to export sessions to files + and import them via a new flag, facilitating session portability + ([#26514](https://github.com/google-gemini/gemini-cli/pull/26514) by + @cocosheng-g). +- **Adaptive Token Estimation:** Introduced an adaptive token calculator for + more accurate content size estimation, enhancing context management efficiency + ([#26888](https://github.com/google-gemini/gemini-cli/pull/26888) by + @joshualitt). + +## Announcements: v0.42.0 - 2026-05-12 + +- **Auto Memory Inbox:** Introduced a new inbox flow for Auto Memory with a + canonical-patch contract for seamless skill management + ([#26338](https://github.com/google-gemini/gemini-cli/pull/26338) by + @SandyTao520). +- **Gemma 4 by Default:** Enabled Gemma 4 models by default via the Gemini API + for all users + ([#26307](https://github.com/google-gemini/gemini-cli/pull/26307) by + @Abhijit-2592). +- **Voice Mode Enhancements:** Added wave animations and privacy/compliance UX + warnings for the Gemini Live backend + ([#26284](https://github.com/google-gemini/gemini-cli/pull/26284) by + @devr0306, [#26454](https://github.com/google-gemini/gemini-cli/pull/26454) by + @cocosheng-g). + +## Announcements: v0.41.0 - 2026-05-05 + +- **Real-time Voice Mode:** Implemented real-time voice mode with cloud and + local backends + ([#24174](https://github.com/google-gemini/gemini-cli/pull/24174) by + @Abhijit-2592). +- **Secure Environment Loading:** Enforced workspace trust and secured .env + loading in headless mode + ([#25814](https://github.com/google-gemini/gemini-cli/pull/25814) by + @ehedlund). +- **Advanced Shell Validation:** Enhanced shell command validation and added + core tools allowlist for improved security + ([#25720](https://github.com/google-gemini/gemini-cli/pull/25720) by @galz10). + +## Announcements: v0.40.0 - 2026-04-28 + +- **Offline Search and Themes:** Bundled ripgrep for offline search support and + added GitHub-style colorblind themes + ([#25342](https://github.com/google-gemini/gemini-cli/pull/25342) by + @scidomino, [#15504](https://github.com/google-gemini/gemini-cli/pull/15504) + by @Z1xus). +- **Advanced Resource and Memory Management:** Introduced MCP resource tools and + transitioned to a prompt-driven, four-tier memory management system + ([#25395](https://github.com/google-gemini/gemini-cli/pull/25395) by + @ruomengz, [#25716](https://github.com/google-gemini/gemini-cli/pull/25716) by + @SandyTao520). +- **UX and Local Models:** Enabled topic update narrations by default and + streamlined Gemma local model setup with `gemini gemma` + ([#25586](https://github.com/google-gemini/gemini-cli/pull/25586) by + @gundermanc, [#25498](https://github.com/google-gemini/gemini-cli/pull/25498) + by @Samee24). + +## Announcements: v0.39.0 - 2026-04-23 + +- **Skill Management:** Added a new `/memory` inbox command for reviewing and + patching skills extracted during sessions + ([#24544](https://github.com/google-gemini/gemini-cli/pull/24544) by + @SandyTao520, [#25148](https://github.com/google-gemini/gemini-cli/pull/25148) + by @SandyTao520). +- **Improved Transparency:** Plan Mode now requires confirmation for skill + activation and allows plan inspection + ([#24946](https://github.com/google-gemini/gemini-cli/pull/24946), + [#25058](https://github.com/google-gemini/gemini-cli/pull/25058) by + @ruomengz). +- **Architecture & Reliability:** Introduced a decoupled `ContextManager` + architecture and resolved several critical memory leaks and PTY exhaustion + issues ([#24752](https://github.com/google-gemini/gemini-cli/pull/24752) by + @joshualitt, [#25079](https://github.com/google-gemini/gemini-cli/pull/25079) + by @spencer426). + +## Announcements: v0.38.0 - 2026-04-14 + +- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on + intent and tool usage for better session structure + ([#23150](https://github.com/google-gemini/gemini-cli/pull/23150) by + @Abhijit-2592, + [#24079](https://github.com/google-gemini/gemini-cli/pull/24079) by + @gundermanc). +- **Context Compression Service:** Advanced context management to efficiently + distill conversation history + ([#24483](https://github.com/google-gemini/gemini-cli/pull/24483) by + @joshualitt). +- **UI Flicker & UX Enhancements:** Solved rendering flicker with "Terminal + Buffer" mode and introduced selective topic expansion + ([#24512](https://github.com/google-gemini/gemini-cli/pull/24512) by + @jacob314, [#24793](https://github.com/google-gemini/gemini-cli/pull/24793) by + @Abhijit-2592). +- **Persistent Policy Approvals:** Implemented context-aware persistent + approvals for tool execution + ([#23257](https://github.com/google-gemini/gemini-cli/pull/23257) by @jerop). + +## Announcements: v0.37.0 - 2026-04-08 + +- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and + worktree support for Linux and Windows, improving developer workflows in + isolated environments + ([#23692](https://github.com/google-gemini/gemini-cli/pull/23692) by @galz10, + [#23691](https://github.com/google-gemini/gemini-cli/pull/23691) by + @scidomino). +- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters") + to provide better session structure and narrative continuity + ([#23150](https://github.com/google-gemini/gemini-cli/pull/23150) by + @Abhijit-2592, + [#24079](https://github.com/google-gemini/gemini-cli/pull/24079) by + @gundermanc). +- **Advanced Browser Capabilities:** Enhanced the browser agent with persistent + sessions and dynamic tool discovery + ([#21306](https://github.com/google-gemini/gemini-cli/pull/21306) by + @kunal-10-cloud, + [#23805](https://github.com/google-gemini/gemini-cli/pull/23805) by + @cynthialong0-0). + +## Announcements: v0.36.0 - 2026-04-01 + +- **Multi-Registry Architecture and Sandboxing:** Introduced a multi-registry + architecture and implemented native macOS Seatbelt and Windows sandboxing for + enhanced subagent security + ([#22712](https://github.com/google-gemini/gemini-cli/pull/22712), + [#22718](https://github.com/google-gemini/gemini-cli/pull/22718) by @akh64bit, + [#22832](https://github.com/google-gemini/gemini-cli/pull/22832) by @ehedlund, + [#21807](https://github.com/google-gemini/gemini-cli/pull/21807) by + @mattKorwel). +- **Refreshed Composer UX:** Implemented a refreshed user experience for the + Composer layout and improved terminal interaction robustness + ([#21212](https://github.com/google-gemini/gemini-cli/pull/21212), + [#23286](https://github.com/google-gemini/gemini-cli/pull/23286) by + @jwhelangoog). +- **Git Worktree Support:** Added native support for Git worktrees, allowing for + isolated parallel sessions + ([#22973](https://github.com/google-gemini/gemini-cli/pull/22973), + [#23265](https://github.com/google-gemini/gemini-cli/pull/23265) by @jerop). +- **Subagent Context and Feedback:** Enhanced subagents with JIT context + injection and resilient tool rejection with contextual feedback + ([#23032](https://github.com/google-gemini/gemini-cli/pull/23032), + [#22951](https://github.com/google-gemini/gemini-cli/pull/22951) by + @abhipatel12). + +## Announcements: v0.35.0 - 2026-03-24 + +- **Customizable Keyboard Shortcuts:** Users can now customize their keyboard + shortcuts, including support for literal character keybindings and the + extended Kitty protocol + ([#21945](https://github.com/google-gemini/gemini-cli/pull/21945), + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) by + @scidomino). +- **Vim Mode Improvements:** Added missing motions (X, ~, r, f/F/t/T) and + yank/paste support with the unnamed register + ([#21932](https://github.com/google-gemini/gemini-cli/pull/21932), + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) by @aanari). +- **Tool Isolation and Sandboxing:** Introduced `SandboxManager` to isolate + process-spawning tools and added Linux bubblewrap/seccomp sandboxing support + ([#21774](https://github.com/google-gemini/gemini-cli/pull/21774), + [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) by @galz10, + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) by + @DavidAPierce). +- **JIT Context Discovery:** Implemented Just-In-Time context discovery for file + system tools to improve model performance and accuracy + ([#22082](https://github.com/google-gemini/gemini-cli/pull/22082), + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) by + @SandyTao520). + +## Announcements: v0.34.0 - 2026-03-17 + +- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help + you break down complex tasks and execute them systematically + ([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop). +- **Sandboxing Enhancements:** We've added native gVisor (runsc) and + experimental LXC container sandboxing support for safer execution environments + ([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by + @Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) + by @h30s). + +## Announcements: v0.33.0 - 2026-03-11 + +- **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A + remote agents and authenticated A2A agent card discovery + ([#20510](https://github.com/google-gemini/gemini-cli/pull/20510) by + @SandyTao520, [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) + by @SandyTao520). +- **Plan Mode Updates:** Expanded Plan Mode with built-in research subagents, + annotation support for feedback, and a new `copy` subcommand + ([#20972](https://github.com/google-gemini/gemini-cli/pull/20972) by @Adib234, + [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) by + @ruomengz). +- **CLI UX & Admin Controls:** Redesigned the header to be compact with an ASCII + icon, inverted context window display to show usage, and enabled a 30-day + default retention for chat history + ([#18713](https://github.com/google-gemini/gemini-cli/pull/18713) by + @keithguerin, [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) + by @skeshive). + +## Announcements: v0.32.0 - 2026-03-03 + +- **Generalist Agent:** The generalist agent is now enabled to improve task + delegation and routing + ([#19665](https://github.com/google-gemini/gemini-cli/pull/19665) by + @joshualitt). +- **Model Steering in Workspace:** Added support for model steering directly in + the workspace + ([#20343](https://github.com/google-gemini/gemini-cli/pull/20343) by + @joshualitt). +- **Plan Mode Enhancements:** Users can now open and modify plans in an external + editor, and the planning workflow has been adapted to handle complex tasks + more effectively with multi-select options + ([#20348](https://github.com/google-gemini/gemini-cli/pull/20348) by @Adib234, + [#20465](https://github.com/google-gemini/gemini-cli/pull/20465) by @jerop). +- **Interactive Shell Autocompletion:** Introduced interactive shell + autocompletion for a more seamless experience + ([#20082](https://github.com/google-gemini/gemini-cli/pull/20082) by + @mrpmohiburrahman). +- **Parallel Extension Loading:** Extensions are now loaded in parallel to + improve startup times + ([#20229](https://github.com/google-gemini/gemini-cli/pull/20229) by + @scidomino). + +## Announcements: v0.31.0 - 2026-02-27 + +- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro + Preview model + ([#19676](https://github.com/google-gemini/gemini-cli/pull/19676) by + @sehoon38). +- **Experimental Browser Agent:** We've introduced a new experimental browser + agent to interact with web pages + ([#19284](https://github.com/google-gemini/gemini-cli/pull/19284) by + @gsquared94). +- **Policy Engine Updates:** The policy engine now supports project-level + policies, MCP server wildcards, and tool annotation matching + ([#18682](https://github.com/google-gemini/gemini-cli/pull/18682) by + @Abhijit-2592, + [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) by @jerop). +- **Web Fetch Improvements:** We've implemented an experimental direct web fetch + feature and added rate limiting to mitigate DDoS risks + ([#19557](https://github.com/google-gemini/gemini-cli/pull/19557) by @mbleigh, + [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) by + @mattKorwel). + +## Announcements: v0.30.0 - 2026-02-25 + +- **SDK & Custom Skills:** Introduced the initial SDK package, enabling dynamic + system instructions, `SessionContext` for SDK tool calls, and support for + custom skills + ([#18861](https://github.com/google-gemini/gemini-cli/pull/18861) by + @mbleigh). +- **Policy Engine Enhancements:** Added a new `--policy` flag for user-defined + policies, introduced strict seatbelt profiles, and deprecated + `--allowed-tools` in favor of the policy engine + ([#18500](https://github.com/google-gemini/gemini-cli/pull/18500) by + @allenhutchison). +- **UI & Themes:** Added a generic searchable list for settings and extensions, + new Solarized themes, text wrapping for markdown tables, and a clean UI toggle + prototype ([#19064](https://github.com/google-gemini/gemini-cli/pull/19064) by + @rmedranollamas). +- **Vim & Terminal Interaction:** Improved Vim support to feel more complete and + added support for Ctrl-Z terminal suspension + ([#18755](https://github.com/google-gemini/gemini-cli/pull/18755) by + @ppgranger, [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) + by @scidomino). + +## Announcements: v0.29.0 - 2026-02-17 + +- **Plan Mode:** A new comprehensive planning capability with `/plan`, + `enter_plan_mode` tool, and dedicated documentation + ([#17698](https://github.com/google-gemini/gemini-cli/pull/17698) by @Adib234, + [#18324](https://github.com/google-gemini/gemini-cli/pull/18324) by @jerop). +- **Gemini 3 Default:** We've removed the preview flag and enabled Gemini 3 by + default for all users + ([#18414](https://github.com/google-gemini/gemini-cli/pull/18414) by + @sehoon38). +- **Extension Exploration:** New UI and settings to explore and manage + extensions more easily + ([#18686](https://github.com/google-gemini/gemini-cli/pull/18686) by + @sripasg). +- **Admin Control:** Administrators can now allowlist specific MCP server + configurations + ([#18311](https://github.com/google-gemini/gemini-cli/pull/18311) by + @skeshive). + +## Announcements: v0.28.0 - 2026-02-10 + +- **IDE Support:** Gemini CLI now supports the Positron IDE + ([#15047](https://github.com/google-gemini/gemini-cli/pull/15047) by + @kapsner). +- **Customization:** You can now use custom themes in extensions, and we've + implemented automatic theme switching based on your terminal's background + ([#17327](https://github.com/google-gemini/gemini-cli/pull/17327) by + @spencer426, [#17976](https://github.com/google-gemini/gemini-cli/pull/17976) + by @Abhijit-2592). +- **Authentication:** We've added interactive and non-interactive consent for + OAuth, and you can now include your auth method in bug reports + ([#17699](https://github.com/google-gemini/gemini-cli/pull/17699) by + @ehedlund, [#17569](https://github.com/google-gemini/gemini-cli/pull/17569) by + @erikus). + +## Announcements: v0.27.0 - 2026-02-03 + +- **Event-Driven Architecture:** The CLI now uses a new event-driven scheduler + for tool execution, resulting in a more responsive and performant experience + ([#17078](https://github.com/google-gemini/gemini-cli/pull/17078) by + @abhipatel12). +- **Enhanced User Experience:** This release includes queued tool confirmations, + and expandable large text pastes for a smoother workflow. +- **New `/rewind` Command:** Easily navigate your session history with the new + `/rewind` command + ([#15720](https://github.com/google-gemini/gemini-cli/pull/15720) by + @Adib234). +- **Linux Clipboard Support:** You can now paste images on Linux with Wayland + and X11 ([#17144](https://github.com/google-gemini/gemini-cli/pull/17144) by + @devr0306). + +## Announcements: v0.26.0 - 2026-01-27 + +- **Agents and Skills:** We've introduced a new `skill-creator` skill + ([#16394](https://github.com/google-gemini/gemini-cli/pull/16394) by + @NTaylorMullen), enabled agent skills by default, and added a generalist agent + to improve task routing + ([#16638](https://github.com/google-gemini/gemini-cli/pull/16638) by + @joshualitt). +- **UI/UX Improvements:** You can now "Rewind" through your conversation history + ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by + @Adib234). +- **Core and Scheduler Refactoring:** The core scheduler has been significantly + refactored to improve performance and reliability + ([#16895](https://github.com/google-gemini/gemini-cli/pull/16895) by + @abhipatel12), and numerous performance and stability fixes have been + included. + +## Announcements: v0.25.0 - 2026-01-20 + +- **Skills and Agents Improvements:** We've enhanced the `activate_skill` tool, + added a new `pr-creator` skill + ([#16232](https://github.com/google-gemini/gemini-cli/pull/16232) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), enabled skills by + default, improved the `cli_help` agent + ([#16100](https://github.com/google-gemini/gemini-cli/pull/16100) by + [@scidomino](https://github.com/scidomino)), and added a new `/agents refresh` + command ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by + [@joshualitt](https://github.com/joshualitt)). +- **UI/UX Refinements:** You'll notice more transparent feedback for skills + ([#15954](https://github.com/google-gemini/gemini-cli/pull/15954) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), the ability to switch + focus between the shell and input with Tab + ([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by + [@jacob314](https://github.com/jacob314)), and dynamic terminal tab titles + ([#16378](https://github.com/google-gemini/gemini-cli/pull/16378) by + [@NTaylorMullen](https://github.com/NTaylorMullen)). +- **Core Functionality & Performance:** This release includes support for + built-in agent skills + ([#16045](https://github.com/google-gemini/gemini-cli/pull/16045) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), refined Gemini 3 system + instructions ([#16139](https://github.com/google-gemini/gemini-cli/pull/16139) + by [@NTaylorMullen](https://github.com/NTaylorMullen)), caching for ignore + instances to improve performance + ([#16185](https://github.com/google-gemini/gemini-cli/pull/16185) by + [@EricRahm](https://github.com/EricRahm)), and enhanced retry mechanisms + ([#16489](https://github.com/google-gemini/gemini-cli/pull/16489) by + [@sehoon38](https://github.com/sehoon38)). +- **Bug Fixes and Stability:** We've squashed numerous bugs across the CLI, + core, and workflows, addressing issues with subagent delegation, unicode + character crashes, and sticky header regressions. + +## Announcements: v0.24.0 - 2026-01-14 + +- **Agent Skills:** We've introduced significant advancements in Agent Skills. + This includes initial documentation and tutorials to help you get started, + alongside enhanced support for remote agents, allowing for more distributed + and powerful automation within Gemini CLI. + ([#15869](https://github.com/google-gemini/gemini-cli/pull/15869) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), + ([#16013](https://github.com/google-gemini/gemini-cli/pull/16013) by + [@adamweidman](https://github.com/adamweidman)) +- **Improved UI/UX:** The user interface has received several updates, featuring + visual indicators for hook execution, a more refined display for settings, and + the ability to use the Tab key to effortlessly switch focus between the shell + and input areas. + ([#15408](https://github.com/google-gemini/gemini-cli/pull/15408) by + [@abhipatel12](https://github.com/abhipatel12)), + ([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by + [@galz10](https://github.com/galz10)) +- **Enhanced Security:** Security has been a major focus, with default folder + trust now set to untrusted for increased safety. The Policy Engine has been + improved to allow specific modes in user and administrator policies, and + granular allowlisting for shell commands has been implemented, providing finer + control over tool execution. + ([#15943](https://github.com/google-gemini/gemini-cli/pull/15943) by + [@galz10](https://github.com/galz10)), + ([#15977](https://github.com/google-gemini/gemini-cli/pull/15977) by + [@NTaylorMullen](https://github.com/NTaylorMullen)) +- **Core Functionality:** This release includes a mandatory MessageBus + injection, marking Phase 3 of a hard migration to a more robust internal + communication system. We've also added support for built-in skills with the + CLI itself, and enhanced model routing to effectively utilize subagents. + ([#15776](https://github.com/google-gemini/gemini-cli/pull/15776) by + [@abhipatel12](https://github.com/abhipatel12)), + ([#16300](https://github.com/google-gemini/gemini-cli/pull/16300) by + [@NTaylorMullen](https://github.com/NTaylorMullen)) +- **Terminal Features:** Terminal interactions are more seamless with new + features like OSC 52 paste support, along with fixes for Windows clipboard + paste issues and general improvements to pasting in Windows terminals. + ([#15336](https://github.com/google-gemini/gemini-cli/pull/15336) by + [@scidomino](https://github.com/scidomino)), + ([#15932](https://github.com/google-gemini/gemini-cli/pull/15932) by + [@scidomino](https://github.com/scidomino)) +- **New Commands:** To manage the new features, we've added several new + commands: `/agents refresh` to update agent configurations, `/skills reload` + to refresh skill definitions, and `/skills install/uninstall` for easier + management of your Agent Skills. + ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), + ([#15865](https://github.com/google-gemini/gemini-cli/pull/15865) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), + ([#16377](https://github.com/google-gemini/gemini-cli/pull/16377) by + [@NTaylorMullen](https://github.com/NTaylorMullen)) + +## Announcements: v0.23.0 - 2026-01-07 + +- πŸŽ‰ **Experimental Agent Skills Support in Preview:** Gemini CLI now supports + [Agent Skills](https://agentskills.io/home) in our preview builds. This is an + early preview where we’re looking for feedback! + - Install Preview: `npm install -g @google/gemini-cli@preview` + - Enable in `/settings` + - Docs: + [https://geminicli.com/docs/cli/skills/](https://geminicli.com/docs/cli/skills/) +- **Gemini CLI wrapped:** Run `npx gemini-wrapped` to visualize your usage + stats, top models, languages, and more! +- **Windows clipboard image support:** Windows users can now paste images + directly from their clipboard into the CLI using `Alt`+`V`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/13997) by + [@sgeraldes](https://github.com/sgeraldes)) +- **Terminal background color detection:** Automatically optimizes your + terminal's background color to select compatible themes and provide + accessibility warnings. + ([pr](https://github.com/google-gemini/gemini-cli/pull/15132) by + [@jacob314](https://github.com/jacob314)) +- **Session logout:** Use the new `/logout` command to instantly clear + credentials and reset your authentication state for seamless account + switching. ([pr](https://github.com/google-gemini/gemini-cli/pull/13383) by + [@CN-Scars](https://github.com/CN-Scars)) + +## Announcements: v0.22.0 - 2025-12-22 + +- πŸŽ‰**Free Tier + Gemini 3:** Free tier users now all have access to Gemini 3 + Pro & Flash. Enable in `/settings` by toggling "Preview Features" to `true`. +- πŸŽ‰**Gemini CLI + Colab:** Gemini CLI is now pre-installed. Can be used + headlessly in notebook cells or interactively in the built-in terminal + ([pic](https://imgur.com/a/G0Tn7vi)) +- πŸŽ‰**Gemini CLI Extensions:** + + - **Conductor:** Planning++, Gemini works with you to build out a detailed + plan, pull in extra details as needed, ultimately to give the LLM guardrails + with artifacts. Measure twice, implement once! + + `gemini extensions install https://github.com/gemini-cli-extensions/conductor` + + Blog: + [https://developers.googleblog.com/conductor-introducing-context-driven-development-for-gemini-cli/](https://developers.googleblog.com/conductor-introducing-context-driven-development-for-gemini-cli/) + + - **Endor Labs:** Perform code analysis, vulnerability scanning, and + dependency checks using natural language. + + `gemini extensions install https://github.com/endorlabs/gemini-extension` + +## Announcements: v0.21.0 - 2025-12-15 + +- **⚑️⚑️⚑️ Gemini 3 Flash + Gemini CLI:** Better, faster and cheaper than 2.5 + Pro - and in some scenarios better than 3 Pro! For paid tiers + free tier + users who were on the wait list enable **Preview Features** in `/settings.` +- For more information: + [Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/). +- πŸŽ‰ Gemini CLI Extensions: + - Rill: Utilize natural language to analyze Rill data, enabling the + exploration of metrics and trends without the need for manual queries. + `gemini extensions install https://github.com/rilldata/rill-gemini-extension` + - Browserbase: Interact with web pages, take screenshots, extract information, + and perform automated actions with atomic precision. + `gemini extensions install https://github.com/browserbase/mcp-server-browserbase` +- Quota Visibility: The `/stats` command now displays quota information for all + available models, including those not used in the current session. (@sehoon38) +- Fuzzy Setting Search: Users can now quickly find settings using fuzzy search + within the settings dialog. (@sehoon38) +- MCP Resource Support: Users can now discover, view, and search through + resources using the @ command. (@MrLesk) +- Auto-execute Simple Slash Commands: Simple slash commands are now executed + immediately on enter. (@jackwotherspoon) + +## Announcements: v0.20.0 - 2025-12-01 + +- **Multi-file Drag & Drop:** Users can now drag and drop multiple files into + the terminal, and the CLI will automatically prefix each valid path with `@`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/14832) by + [@jackwotherspoon](https://github.com/jackwotherspoon)) +- **Persistent "Always Allow" Policies:** Users can now save "Always Allow" + decisions for tool executions, with granular control over specific shell + commands and multi-cloud platform tools. + ([pr](https://github.com/google-gemini/gemini-cli/pull/14737) by + [@allenhutchison](https://github.com/allenhutchison)) + +## Announcements: v0.19.0 - 2025-11-24 + +- πŸŽ‰ **New extensions:** + - **Eleven Labs:** Create, play, manage your audio play tracks with the Eleven + Labs Gemini CLI extension: + `gemini extensions install https://github.com/elevenlabs/elevenlabs-mcp` +- **Zed integration:** Users can now leverage Gemini 3 within the Zed + integration after enabling "Preview Features" in their CLI’s `/settings`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/13398) by + [@benbrandt](https://github.com/benbrandt)) +- **Interactive shell:** + - **Click-to-Focus:** When "Use Alternate Buffer" setting is enabled, users + can click within the embedded shell output to focus it for input. + ([pr](https://github.com/google-gemini/gemini-cli/pull/13341) by + [@galz10](https://github.com/galz10)) + - **Loading phrase:** Clearly indicates when the interactive shell is awaiting + user input. ([vid](https://imgur.com/a/kjK8bUK), + [pr](https://github.com/google-gemini/gemini-cli/pull/12535) by + [@jackwotherspoon](https://github.com/jackwotherspoon)) + +## Announcements: v0.18.0 - 2025-11-17 + +- πŸŽ‰ **New extensions:** + - **Google Workspace**: Integrate Gemini CLI with your Workspace data. Write + docs, build slides, chat with others or even get your calc on in sheets: + `gemini extensions install https://github.com/gemini-cli-extensions/workspace` + - Blog: + [https://allen.hutchison.org/2025/11/19/bringing-the-office-to-the-terminal/](https://allen.hutchison.org/2025/11/19/bringing-the-office-to-the-terminal/) + - **Redis:** Manage and search data in Redis with natural language: + `gemini extensions install https://github.com/redis/mcp-redis` + - **Anomalo:** Query your data warehouse table metadata and quality status + through commands and natural language: + `gemini extensions install https://github.com/datagravity-ai/anomalo-gemini-extension` +- **Experimental permission improvements:** We are now experimenting with a new + policy engine in Gemini CLI. This allows users and administrators to create + fine-grained policy for tool calls. Currently behind a flag. See + [policy engine documentation](../reference/policy-engine.md) for more + information. + - Blog: + [https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/) +- **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API + key, Google AI Pro or Google AI Ultra (for individuals, not businesses) and + Gemini Code Assist Enterprise users. Enable it via `/settings` and toggling on + **Preview Features**. +- **Updated UI rollback:** We’ve temporarily rolled back our updated UI to give + it more time to bake. This means for a time you won’t have embedded scrolling + or mouse support. You can re-enable with `/settings` -> **Use Alternate Screen + Buffer** -> `true`. +- **Model in history:** Users can now toggle in `/settings` to display model in + their chat history. ([gif](https://imgur.com/a/uEmNKnQ), + [pr](https://github.com/google-gemini/gemini-cli/pull/13034) by + [@scidomino](https://github.com/scidomino)) +- **Multi-uninstall:** Users can now uninstall multiple extensions with a single + command. ([pic](https://imgur.com/a/9Dtq8u2), + [pr](https://github.com/google-gemini/gemini-cli/pull/13016) by + [@JayadityaGit](https://github.com/JayadityaGit)) + +## Announcements: v0.16.0 - 2025-11-10 + +- **Gemini 3 + Gemini CLI:** launch πŸš€πŸš€πŸš€ +- **Data Commons Gemini CLI Extension** - A new Data Commons Gemini CLI + extension that lets you query open-source statistical data from + datacommons.org. **To get started, you'll need a Data Commons API key and uv + installed**. These and other details to get you started with the extension can + be found at + [https://github.com/gemini-cli-extensions/datacommons](https://github.com/gemini-cli-extensions/datacommons). + +## Announcements: v0.15.0 - 2025-11-03 + +- **πŸŽ‰ Seamless scrollable UI and mouse support:** We’ve given Gemini CLI a + major facelift to make your terminal experience smoother and much more + polished. You now get a flicker-free display with sticky headers that keep + important context visible and a stable input prompt that doesn't jump around. + We even added mouse support so you can click right where you need to type! + ([gif](https://imgur.com/a/O6qc7bx), + [@jacob314](https://github.com/jacob314)). + - **Announcement:** + [https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/) +- **πŸŽ‰ New partner extensions:** + + - **Arize:** Seamlessly instrument AI applications with Arize AX and grant + direct access to Arize support: + + `gemini extensions install https://github.com/Arize-ai/arize-tracing-assistant` + + - **Chronosphere:** Retrieve logs, metrics, traces, events, and specific + entities: + + `gemini extensions install https://github.com/chronosphereio/chronosphere-mcp` + + - **Transmit:** Comprehensive context, validation, and automated fixes for + creating production-ready authentication and identity workflows: + + `gemini extensions install https://github.com/TransmitSecurity/transmit-security-journey-builder` + +- **Todo planning:** Complex questions now get broken down into todo lists that + the model can manage and check off. ([gif](https://imgur.com/a/EGDfNlZ), + [pr](https://github.com/google-gemini/gemini-cli/pull/12905) by + [@anj-s](https://github.com/anj-s)) +- **Disable GitHub extensions:** Users can now prevent the installation and + loading of extensions from GitHub. + ([pr](https://github.com/google-gemini/gemini-cli/pull/12838) by + [@kevinjwang1](https://github.com/kevinjwang1)). +- **Extensions restart:** Users can now explicitly restart extensions using the + `/extensions restart` command. + ([pr](https://github.com/google-gemini/gemini-cli/pull/12739) by + [@jakemac53](https://github.com/jakemac53)). +- **Better Angular support:** Angular workflows should now be more seamless + ([pr](https://github.com/google-gemini/gemini-cli/pull/10252) by + [@MarkTechson](https://github.com/MarkTechson)). +- **Validate command:** Users can now check that local extensions are formatted + correctly. ([pr](https://github.com/google-gemini/gemini-cli/pull/12186) by + [@kevinjwang1](https://github.com/kevinjwang1)). + +## Announcements: v0.12.0 - 2025-10-27 + +![Codebase investigator subagent in Gemini CLI.](https://i.imgur.com/4J1njsx.png) + +- **πŸŽ‰ New partner extensions:** + + - **πŸ€— Hugging Face extension:** Access the Hugging Face hub. + ([gif](https://drive.google.com/file/d/1LEzIuSH6_igFXq96_tWev11svBNyPJEB/view?usp=sharing&resourcekey=0-LtPTzR1woh-rxGtfPzjjfg)) + + `gemini extensions install https://github.com/huggingface/hf-mcp-server` + + - **Monday.com extension**: Analyze your sprints, update your task boards, + etc. + ([gif](https://drive.google.com/file/d/1cO0g6kY1odiBIrZTaqu5ZakaGZaZgpQv/view?usp=sharing&resourcekey=0-xEr67SIjXmAXRe1PKy7Jlw)) + + `gemini extensions install https://github.com/mondaycom/mcp` + + - **Data Commons extension:** Query public datasets or ground responses on + data from Data Commons + ([gif](https://drive.google.com/file/d/1cuj-B-vmUkeJnoBXrO_Y1CuqphYc6p-O/view?usp=sharing&resourcekey=0-0adXCXDQEd91ZZW63HbW-Q)). + + `gemini extensions install https://github.com/gemini-cli-extensions/datacommons` + +- **Model selection:** Choose the Gemini model for your session with `/model`. + ([pic](https://imgur.com/a/ABFcWWw), + [pr](https://github.com/google-gemini/gemini-cli/pull/8940) by + [@abhipatel12](https://github.com/abhipatel12)). +- **Model routing:** Gemini CLI will now intelligently pick the best model for + the task. Simple queries will be sent to Flash while complex analytical or + creative tasks will still use the power of Pro. This ensures your quota will + last for a longer period of time. You can always opt-out of this via `/model`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/9262) by + [@abhipatel12](https://github.com/abhipatel12)). + - Discussion: + [https://github.com/google-gemini/gemini-cli/discussions/12375](https://github.com/google-gemini/gemini-cli/discussions/12375) +- **Codebase investigator subagent:** We now have a new built-in subagent that + will explore your workspace and resolve relevant information to improve + overall performance. + ([pr](https://github.com/google-gemini/gemini-cli/pull/9988) by + [@abhipatel12](https://github.com/abhipatel12), + [pr](https://github.com/google-gemini/gemini-cli/pull/10282) by + [@silviojr](https://github.com/silviojr)). + - Enable, disable, or limit turns in `/settings`, plus advanced configs in + `settings.json` ([pic](https://imgur.com/a/yJiggNO), + [pr](https://github.com/google-gemini/gemini-cli/pull/10844) by + [@silviojr](https://github.com/silviojr)). +- **Explore extensions with `/extension`:** Users can now open the extensions + page in their default browser directly from the CLI using the `/extension` + explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846) + by [@JayadityaGit](https://github.com/JayadityaGit)). +- **Configurable compression:** Users can modify the context compression + threshold in `/settings` (decimal with percentage display). The default has + been made more proactive + ([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by + [@scidomino](https://github.com/scidomino)). +- **API key authentication:** Users can now securely enter and store their + Gemini API key via a new dialog, eliminating the need for environment + variables and repeated entry. + ([pr](https://github.com/google-gemini/gemini-cli/pull/11760) by + [@galz10](https://github.com/galz10)). +- **Sequential approval:** Users can now approve multiple tool calls + sequentially during execution. + ([pr](https://github.com/google-gemini/gemini-cli/pull/11593) by + [@joshualitt](https://github.com/joshualitt)). + +## Announcements: v0.11.0 - 2025-10-20 + +![Gemini CLI and Jules](https://storage.googleapis.com/gweb-developer-goog-blog-assets/images/Jules_Extension_-_Blog_Header_O346JNt.original.png) + +- πŸŽ‰ **Gemini CLI Jules Extension:** Use Gemini CLI to orchestrate Jules. Spawn + remote workers, delegate tedious tasks, or check in on running jobs! + - Install: + `gemini extensions install https://github.com/gemini-cli-extensions/jules` + - Announcement: + [https://developers.googleblog.com/en/introducing-the-jules-extension-for-gemini-cli/](https://developers.googleblog.com/en/introducing-the-jules-extension-for-gemini-cli/) +- **Stream JSON output:** Stream real-time JSONL events with + `--output-format stream-json` to monitor AI agent progress when run + headlessly. ([gif](https://imgur.com/a/0UCE81X), + [pr](https://github.com/google-gemini/gemini-cli/pull/10883) by + [@anj-s](https://github.com/anj-s)) +- **Markdown toggle:** Users can now switch between rendered and raw markdown + display using `alt+m `or` ctrl+m`. ([gif](https://imgur.com/a/lDNdLqr), + [pr](https://github.com/google-gemini/gemini-cli/pull/10383) by + [@srivatsj](https://github.com/srivatsj)) +- **Queued message editing:** Users can now quickly edit queued messages by + pressing the up arrow key when the input is empty. + ([gif](https://imgur.com/a/ioRslLd), + [pr](https://github.com/google-gemini/gemini-cli/pull/10392) by + [@akhil29](https://github.com/akhil29)) +- **JSON web fetch**: Non-HTML content like JSON APIs or raw source code are now + properly shown to the model (previously only supported HTML) + ([gif](https://imgur.com/a/Q58U4qJ), + [pr](https://github.com/google-gemini/gemini-cli/pull/11284) by + [@abhipatel12](https://github.com/abhipatel12)) +- **Non-interactive MCP commands:** Users can now run MCP slash commands in + non-interactive mode `gemini "/some-mcp-prompt"`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10194) by + [@capachino](https://github.com/capachino)) +- **Removal of deprecated flags:** We’ve finally removed a number of deprecated + flags to cleanup Gemini CLI’s invocation profile: + - `--all-files` / `-a` in favor of `@` from within Gemini CLI. + ([pr](https://github.com/google-gemini/gemini-cli/pull/11228) by + [@allenhutchison](https://github.com/allenhutchison)) + - `--telemetry-*` flags in favor of + [environment variables](https://github.com/google-gemini/gemini-cli/pull/11318) + ([pr](https://github.com/google-gemini/gemini-cli/pull/11318) by + [@allenhutchison](https://github.com/allenhutchison)) + +## Announcements: v0.10.0 - 2025-10-13 + +- **Polish:** The team has been heads down bug fixing and investing heavily into + polishing existing flows, tools, and interactions. +- **Interactive Shell Tool calling:** Gemini CLI can now also execute + interactive tools if needed + ([pr](https://github.com/google-gemini/gemini-cli/pull/11225) by + [@galz10](https://github.com/galz10)). +- **Alt+Key support:** Enables broader support for Alt+Key keyboard shortcuts + across different terminals. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10767) by + [@srivatsj](https://github.com/srivatsj)). +- **Telemetry Diff stats:** Track line changes made by the model and user during + file operations via OTEL. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10819) by + [@jerop](https://github.com/jerop)). + +## Announcements: v0.9.0 - 2025-10-06 + +- πŸŽ‰ **Interactive Shell:** Run interactive commands like `vim`, `rebase -i`, or + even `gemini` 😎 directly in Gemini CLI: + - Blog: + [https://developers.googleblog.com/en/say-hello-to-a-new-level-of-interactivity-in-gemini-cli/](https://developers.googleblog.com/en/say-hello-to-a-new-level-of-interactivity-in-gemini-cli/) +- **Install pre-release extensions:** Install the latest `--pre-release` + versions of extensions. Used for when an extension’s release hasn’t been + marked as "latest". + ([pr](https://github.com/google-gemini/gemini-cli/pull/10752) by + [@jakemac53](https://github.com/jakemac53)) +- **Simplified extension creation:** Create a new, empty extension. Templates + are no longer required. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10629) by + [@chrstnb](https://github.com/chrstnb)) +- **OpenTelemetry GenAI metrics:** Aligns telemetry with industry-standard + semantic conventions for improved interoperability. + ([spec](https://opentelemetry.io/docs/concepts/semantic-conventions/), + [pr](https://github.com/google-gemini/gemini-cli/pull/10343) by + [@jerop](https://github.com/jerop)) +- **List memory files:** Quickly find the location of your long-term memory + files with `/memory list`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/10108) by + [@sgnagnarella](https://github.com/sgnagnarella)) + +## Announcements: v0.8.0 - 2025-09-29 + +- πŸŽ‰ **Announcing Gemini CLI Extensions** πŸŽ‰ + - Completely customize your Gemini CLI experience to fit your workflow. + - Build and share your own Gemini CLI extensions with the world. + - Launching with a growing catalog of community, partner, and Google-built + extensions. + - Check extensions from + [key launch partners](https://github.com/google-gemini/gemini-cli/discussions/10718). + - Easy install: + - `gemini extensions install ` + - Easy management: + - `gemini extensions install|uninstall|link` + - `gemini extensions enable|disable` + - `gemini extensions list|update|new` + - Or use commands while running with `/extensions list|update`. + - Everything you need to know: + [Now open for building: Introducing Gemini CLI extensions](https://blog.google/technology/developers/gemini-cli-extensions/). +- πŸŽ‰ **Our New Home Page & Better Documentation** πŸŽ‰ + - Check out our new home page for better getting started material, reference + documentation, extensions and more! + - _Homepage:_ [https://geminicli.com](https://geminicli.com) + - ‼️*NEW documentation:* + [https://geminicli.com/docs](https://geminicli.com/docs) (Have any + [suggestions](https://github.com/google-gemini/gemini-cli/discussions/8722)?) + - _Extensions:_ + [https://geminicli.com/extensions](https://geminicli.com/extensions) +- **Non-Interactive Allowed Tools:** `--allowed-tools` will now also work in + non-interactive mode. + ([pr](https://github.com/google-gemini/gemini-cli/pull/9114) by + [@mistergarrison](https://github.com/mistergarrison)) +- **Terminal Title Status:** See the CLI's real-time status and thoughts + directly in the terminal window's title by setting `showStatusInTitle: true`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/4386) by + [@Fridayxiao](https://github.com/Fridayxiao)) +- **Small features, polish, reliability & bug fixes:** A large amount of + changes, smaller features, UI updates, reliability and bug fixes + general + polish made it in this week! + +## Announcements: v0.7.0 - 2025-09-22 + +- πŸŽ‰**Build your own Gemini CLI IDE plugin:** We've published a spec for + creating IDE plugins to enable rich context-aware experiences and native + in-editor diffing in your IDE of choice. + ([pr](https://github.com/google-gemini/gemini-cli/pull/8479) by + [@skeshive](https://github.com/skeshive)) +- πŸŽ‰ **Gemini CLI extensions** + - **Flutter:** An early version to help you create, build, test, and run + Flutter apps with Gemini CLI + ([extension](https://github.com/gemini-cli-extensions/flutter)) + - **nanobanana:** Integrate nanobanana into Gemini CLI + ([extension](https://github.com/gemini-cli-extensions/nanobanana)) +- **Telemetry config via environment:** Manage telemetry settings using + environment variables for a more flexible setup. + ([docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/telemetry.md#configuration), + [pr](https://github.com/google-gemini/gemini-cli/pull/9113) by + [@jerop](https://github.com/jerop)) +- **​​Experimental todos:** Track and display progress on complex tasks with a + managed checklist. Off by default but can be enabled via + `"useWriteTodos": true` + ([pr](https://github.com/google-gemini/gemini-cli/pull/8761) by + [@anj-s](https://github.com/anj-s)) +- **Share chat support for tools:** Using `/chat share` will now also render + function calls and responses in the final markdown file. + ([pr](https://github.com/google-gemini/gemini-cli/pull/8693) by + [@rramkumar1](https://github.com/rramkumar1)) +- **Citations:** Now enabled for all users + ([pr](https://github.com/google-gemini/gemini-cli/pull/8570) by + [@scidomino](https://github.com/scidomino)) +- **Custom commands in Headless Mode:** Run custom slash commands directly from + the command line in non-interactive mode: `gemini "/joke Chuck Norris"` + ([pr](https://github.com/google-gemini/gemini-cli/pull/8305) by + [@capachino](https://github.com/capachino)) +- **Small features, polish, reliability & bug fixes:** A large amount of + changes, smaller features, UI updates, reliability and bug fixes + general + polish made it in this week! + +## Announcements: v0.6.0 - 2025-09-15 + +- πŸŽ‰ **Higher limits for Google AI Pro and Ultra subscribers:** We’re psyched to + finally announce that Google AI Pro and AI Ultra subscribers now get access to + significantly higher 2.5 quota limits for Gemini CLI! + - **Announcement:** + [https://blog.google/technology/developers/gemini-cli-code-assist-higher-limits/](https://blog.google/technology/developers/gemini-cli-code-assist-higher-limits/) +- πŸŽ‰**Gemini CLI Databases and BigQuery Extensions:** Connect Gemini CLI to all + of your cloud data with Gemini CLI. + - Announcement and how to get started with each of the below extensions: + [https://cloud.google.com/blog/products/databases/gemini-cli-extensions-for-google-data-cloud?e=48754805](https://cloud.google.com/blog/products/databases/gemini-cli-extensions-for-google-data-cloud?e=48754805) + - **AlloyDB:** Interact, manage and observe AlloyDB for PostgreSQL databases + ([manage](https://github.com/gemini-cli-extensions/alloydb#configuration), + [observe](https://github.com/gemini-cli-extensions/alloydb-observability#configuration)) + - **BigQuery:** Connect and query your BigQuery datasets or utilize a + sub-agent for contextual insights + ([query](https://github.com/gemini-cli-extensions/bigquery-data-analytics#configuration), + [sub-agent](https://github.com/gemini-cli-extensions/bigquery-conversational-analytics)) + - **Cloud SQL:** Interact, manage and observe Cloud SQL for PostgreSQL + ([manage](https://github.com/gemini-cli-extensions/cloud-sql-postgresql#configuration),[ observe](https://github.com/gemini-cli-extensions/cloud-sql-postgresql-observability#configuration)), + Cloud SQL for MySQL + ([manage](https://github.com/gemini-cli-extensions/cloud-sql-mysql#configuration),[ observe](https://github.com/gemini-cli-extensions/cloud-sql-mysql-observability#configuration)) + and Cloud SQL for SQL Server + ([manage](https://github.com/gemini-cli-extensions/cloud-sql-sqlserver#configuration),[ observe](https://github.com/gemini-cli-extensions/cloud-sql-sqlserver-observability#configuration)) + databases. + - **Dataplex:** Discover, manage, and govern data and AI artifacts + ([extension](https://github.com/gemini-cli-extensions/dataplex#configuration)) + - **Firestore:** Interact with Firestore databases, collections and documents + ([extension](https://github.com/gemini-cli-extensions/firestore-native#configuration)) + - **Looker:** Query data, run Looks and create dashboards + ([extension](https://github.com/gemini-cli-extensions/looker#configuration)) + - **MySQL:** Interact with MySQL databases + ([extension](https://github.com/gemini-cli-extensions/mysql#configuration)) + - **Postgres:** Interact with PostgreSQL databases + ([extension](https://github.com/gemini-cli-extensions/postgres#configuration)) + - **Spanner:** Interact with Spanner databases + ([extension](https://github.com/gemini-cli-extensions/spanner#configuration)) + - **SQL Server:** Interact with SQL Server databases + ([extension](https://github.com/gemini-cli-extensions/sql-server#configuration)) + - **MCP Toolbox:** Configure and load custom tools for more than 30+ data + sources + ([extension](https://github.com/gemini-cli-extensions/mcp-toolbox#configuration)) +- **JSON output mode:** Have Gemini CLI output JSON with `--output-format json` + when invoked headlessly for easy parsing and post-processing. Includes + response, stats and errors. + ([pr](https://github.com/google-gemini/gemini-cli/pull/8119) by + [@jerop](https://github.com/jerop)) +- **Keybinding triggered approvals:** When you use shortcuts (`shift+y` or + `shift+tab`) to activate YOLO/auto-edit modes any pending confirmation dialogs + will now approve. ([pr](https://github.com/google-gemini/gemini-cli/pull/6665) + by [@bulkypanda](https://github.com/bulkypanda)) +- **Chat sharing:** Convert the current conversation to a Markdown or JSON file + with _/chat share <file.md|file.json>_ + ([pr](https://github.com/google-gemini/gemini-cli/pull/8139) by + [@rramkumar1](https://github.com/rramkumar1)) +- **Prompt search:** Search your prompt history using `ctrl+r`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/5539) by + [@Aisha630](https://github.com/Aisha630)) +- **Input undo/redo:** Recover accidentally deleted text in the input prompt + using `ctrl+z` (undo) and `ctrl+shift+z` (redo). + ([pr](https://github.com/google-gemini/gemini-cli/pull/4625) by + [@masiafrest](https://github.com/masiafrest)) +- **Loop detection confirmation:** When loops are detected you are now presented + with a dialog to disable detection for the current session. + ([pr](https://github.com/google-gemini/gemini-cli/pull/8231) by + [@SandyTao520](https://github.com/SandyTao520)) +- **Direct to Google Cloud Telemetry:** Directly send telemetry to Google Cloud + for a simpler and more streamlined setup. + ([pr](https://github.com/google-gemini/gemini-cli/pull/8541) by + [@jerop](https://github.com/jerop)) +- **Visual Mode Indicator Revamp:** β€˜shell’, 'accept edits' and 'yolo' modes now + have colors to match their impact / usage. Input box now also updates. + ([shell](https://imgur.com/a/DovpVF1), + [accept-edits](https://imgur.com/a/33KDz3J), + [yolo](https://imgur.com/a/tbFwIWp), + [pr](https://github.com/google-gemini/gemini-cli/pull/8200) by + [@miguelsolorio](https://github.com/miguelsolorio)) +- **Small features, polish, reliability & bug fixes:** A large amount of + changes, smaller features, UI updates, reliability and bug fixes + general + polish made it in this week! + +## Announcements: v0.5.0 - 2025-09-08 + +- πŸŽ‰**FastMCP + Gemini CLI**πŸŽ‰: Quickly install and manage your Gemini CLI MCP + servers with FastMCP ([video](https://imgur.com/a/m8QdCPh), + [pr](https://github.com/jlowin/fastmcp/pull/1709) by + [@jackwotherspoon](https://github.com/jackwotherspoon)**)** + - Getting started: + [https://gofastmcp.com/integrations/gemini-cli](https://gofastmcp.com/integrations/gemini-cli) +- **Positional Prompt for Non-Interactive:** Seamlessly invoke Gemini CLI + headlessly via `gemini "Hello"`. Synonymous with passing `-p`. + ([gif](https://imgur.com/a/hcBznpB), + [pr](https://github.com/google-gemini/gemini-cli/pull/7668) by + [@allenhutchison](https://github.com/allenhutchison)) +- **Experimental Tool output truncation:** Enable truncating shell tool outputs + and saving full output to a file by setting + `"enableToolOutputTruncation": true `([pr](https://github.com/google-gemini/gemini-cli/pull/8039) + by [@SandyTao520](https://github.com/SandyTao520)) +- **Edit Tool improvements:** Gemini CLI’s ability to edit files should now be + far more capable. ([pr](https://github.com/google-gemini/gemini-cli/pull/7679) + by [@silviojr](https://github.com/silviojr)) +- **Custom witty messages:** The feature you’ve all been waiting for… + Personalized witty loading messages via + `"ui": { "customWittyPhrases": ["YOLO"]}` in `settings.json`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/7641) by + [@JayadityaGit](https://github.com/JayadityaGit)) +- **Nested .gitignore File Handling:** Nested `.gitignore` files are now + respected. ([pr](https://github.com/google-gemini/gemini-cli/pull/7645) by + [@gsquared94](https://github.com/gsquared94)) +- **Enforced authentication:** System administrators can now mandate a specific + authentication method via + `"enforcedAuthType": "oauth-personal|gemini-api-key|…"`in `settings.json`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/6564) by + [@chrstnb](https://github.com/chrstnb)) +- **A2A development-tool extension:** An RFC for an Agent2Agent + ([A2A](https://a2a-protocol.org/latest/)) powered extension for developer tool + use cases. + ([feedback](https://github.com/google-gemini/gemini-cli/discussions/7822), + [pr](https://github.com/google-gemini/gemini-cli/pull/7817) by + [@skeshive](https://github.com/skeshive)) +- **Hands on Codelab: + **[https://codelabs.developers.google.com/gemini-cli-hands-on](https://codelabs.developers.google.com/gemini-cli-hands-on) +- **Small features, polish, reliability & bug fixes:** A large amount of + changes, smaller features, UI updates, reliability and bug fixes + general + polish made it in this week! + +## Announcements: v0.4.0 - 2025-09-01 + +- πŸŽ‰**Gemini CLI CloudRun and Security Integrations**πŸŽ‰: Automate app deployment + and security analysis with CloudRun and Security extension integrations. Once + installed deploy your app to the cloud with `/deploy` and find and fix + security vulnerabilities with `/security:analyze`. + - Announcement and how to get started: + [https://cloud.google.com/blog/products/ai-machine-learning/automate-app-deployment-and-security-analysis-with-new-gemini-cli-extensions](https://cloud.google.com/blog/products/ai-machine-learning/automate-app-deployment-and-security-analysis-with-new-gemini-cli-extensions) +- **Experimental** + - **Edit Tool:** Give our new edit tool a try by setting + `"useSmartEdit": true` in `settings.json`! + ([feedback](https://github.com/google-gemini/gemini-cli/discussions/7758), + [pr](https://github.com/google-gemini/gemini-cli/pull/6823) by + [@silviojr](https://github.com/silviojr)) + - **Model talking to itself fix:** We’ve removed a model workaround that would + encourage Gemini CLI to continue conversations on your behalf. This may be + disruptive and can be disabled via `"skipNextSpeakerCheck": false` in your + `settings.json` + ([feedback](https://github.com/google-gemini/gemini-cli/discussions/6666), + [pr](https://github.com/google-gemini/gemini-cli/pull/7614) by + [@SandyTao520](https://github.com/SandyTao520)) + - **Prompt completion:** Get real-time AI suggestions to complete your prompts + as you type. Enable it with `"general": { "enablePromptCompletion": true }` + and share your feedback! + ([gif](https://miro.medium.com/v2/resize:fit:2000/format:webp/1*hvegW7YXOg6N_beUWhTdxA.gif), + [pr](https://github.com/google-gemini/gemini-cli/pull/4691) by + [@3ks](https://github.com/3ks)) +- **Footer visibility configuration:** Customize the CLI's footer look and feel + in `settings.json` + ([pr](https://github.com/google-gemini/gemini-cli/pull/7419) by + [@miguelsolorio](https://github.com/miguelsolorio)) + - `hideCWD`: hide current working directory. + - `hideSandboxStatus`: hide sandbox status. + - `hideModelInfo`: hide current model information. + - `hideContextSummary`: hide request context summary. +- **Citations:** For enterprise Code Assist licenses users will now see + citations in their responses by default. Enable this yourself with + `"showCitations": true` + ([pr](https://github.com/google-gemini/gemini-cli/pull/7350) by + [@scidomino](https://github.com/scidomino)) +- **Pro Quota Dialog:** Handle daily Pro model usage limits with an interactive + dialog that lets you immediately switch auth or fallback. + ([pr](https://github.com/google-gemini/gemini-cli/pull/7094) by + [@JayadityaGit](https://github.com/JayadityaGit)) +- **Custom commands @:** Embed local file or directory content directly into + your custom command prompts using `@{path}` syntax + ([gif](https://miro.medium.com/v2/resize:fit:2000/format:webp/1*GosBAo2SjMfFffAnzT7ZMg.gif), + [pr](https://github.com/google-gemini/gemini-cli/pull/6716) by + [@abhipatel12](https://github.com/abhipatel12)) +- **2.5 Flash Lite support:** You can now use the `gemini-2.5-flash-lite` model + for Gemini CLI via `gemini -m …`. + ([gif](https://miro.medium.com/v2/resize:fit:2000/format:webp/1*P4SKwnrsyBuULoHrFqsFKQ.gif), + [pr](https://github.com/google-gemini/gemini-cli/pull/4652) by + [@psinha40898](https://github.com/psinha40898)) +- **CLI streamlining:** We have deprecated a number of command line arguments in + favor of `settings.json`Β alternatives. We will remove these arguments in a + future release. See the PR for the full list of deprecations. + ([pr](https://github.com/google-gemini/gemini-cli/pull/7360) by + [@allenhutchison](https://github.com/allenhutchison)) +- **JSON session summary:** Track and save detailed CLI session statistics to a + JSON file for performance analysis with `--session-summary ` + ([pr](https://github.com/google-gemini/gemini-cli/pull/7347) by + [@leehagoodjames](https://github.com/leehagoodjames)) +- **Robust keyboard handling:** More reliable and consistent behavior for arrow + keys, special keys (Home, End, etc.), and modifier combinations across various + terminals. ([pr](https://github.com/google-gemini/gemini-cli/pull/7118) by + [@deepankarsharma](https://github.com/deepankarsharma)) +- **MCP loading indicator:** Provides visual feedback during CLI initialization + when connecting to multiple servers. + ([pr](https://github.com/google-gemini/gemini-cli/pull/6923) by + [@swissspidy](https://github.com/swissspidy)) +- **Small features, polish, reliability & bug fixes:** A large amount of + changes, smaller features, UI updates, reliability and bug fixes + general + polish made it in this week! diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md new file mode 100644 index 0000000000000000000000000000000000000000..88a5e97ac1b201c16dffa94807e4e324448943b3 --- /dev/null +++ b/docs/changelogs/latest.md @@ -0,0 +1,72 @@ +# Latest stable release: v0.60.0 + +Released: September 15, 2026 + +For most users, our latest stable release is the recommended release. Install +the latest stable version with: + +``` +npm install -g @google/gemini-cli +``` + +## Highlights + +- **Extension Consent & Isolation:** Introduced explicit prompting for user + consent on environment changes requested by extensions, along with + sanitization of runtime-altering environment variables to ensure secure + execution environments. +- **Sandbox & Directory Hardening:** Isolated the settings directory and + temporary directories within sandbox containers, including specific path + isolation enhancements for the macOS Seatbelt sandbox. +- **Robust Boundary & Path Resolution:** Hardened workspace boundary validation, + symlink resolution, and NTFS 8.3 short name (SFN) path mitigation across + command safety and file discovery utilities. +- **Enhanced API & Connection Security:** Implemented strict RFC 9207 issuer + identification in the Model Context Protocol (MCP) OAuth flow, along with + improved destination validation and connection routing inside web fetch + utilities. + +## What's Changed + +- fix(core): improve destination validation and connection routing in web fetch + utilities by @diegogodinezr in + [#29120](https://github.com/google-gemini/gemini-cli/pull/29120) +- fix(core): enforce RFC 9207 issuer identification in MCP OAuth flow by + @jvargassanchez-dot in + [#29117](https://github.com/google-gemini/gemini-cli/pull/29117) +- chore(release): bump version to 0.60.0-nightly.20260901.g0bd1d4397 by + @gemini-cli-robot in + [#29162](https://github.com/google-gemini/gemini-cli/pull/29162) +- Changelog for v0.58.0 by @gemini-cli-robot in + [#29161](https://github.com/google-gemini/gemini-cli/pull/29161) +- fix(cli): isolate temporary directory for macOS Seatbelt sandbox by + @jvargassanchez-dot in + [#29171](https://github.com/google-gemini/gemini-cli/pull/29171) +- feat(extensions): harden path resolution and boundary validation in extension + loader by @diegogodinezr in + [#29169](https://github.com/google-gemini/gemini-cli/pull/29169) +- Changelog for v0.59.0-preview.0 by @gemini-cli-robot in + [#29159](https://github.com/google-gemini/gemini-cli/pull/29159) +- fix(core): sanitize and remove hardcoded Google CrUX API key in + chrome-devtools-mcp by @amelidev in + [#29158](https://github.com/google-gemini/gemini-cli/pull/29158) +- fix(extensions): prompt for consent on environment changes and sanitize + runtime-altering environment variables by @amelidev in + [#28863](https://github.com/google-gemini/gemini-cli/pull/28863) +- fix(core): enhance workspace path boundary checks and symlink resolution in + command safety and file discovery by @jesussamuel-byte in + [#29170](https://github.com/google-gemini/gemini-cli/pull/29170) +- fix(config): enforce strict permission and ownership checks on system-wide + configuration paths by @jesussamuel-byte in + [#29115](https://github.com/google-gemini/gemini-cli/pull/29115) +- fix(core): mitigate NTFS 8.3 short name (SFN) path by @urielefrenvirtusa in + [#29116](https://github.com/google-gemini/gemini-cli/pull/29116) +- fix(cli): isolate settings directory in sandbox containers by + @jvargassanchez-dot in + [#29216](https://github.com/google-gemini/gemini-cli/pull/29216) +- fix(core): enforce envelope metadata provenance for untrusted tool outputs by + @luisfelipe-alt in + [#29215](https://github.com/google-gemini/gemini-cli/pull/29215) + +**Full Changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.59.0...v0.60.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md new file mode 100644 index 0000000000000000000000000000000000000000..fd589681346e6a121efb385320880d0df36eddfc --- /dev/null +++ b/docs/changelogs/preview.md @@ -0,0 +1,49 @@ +# Preview release: v0.61.0-preview.0 + +Released: September 15, 2026 + +Our preview release includes the latest, new, and experimental features. This +release may not be as stable as our [latest weekly release](latest.md). + +To install the preview release: + +``` +npm install -g @google/gemini-cli@preview +``` + +## Highlights + +- **Sandbox Isolation**: Enhanced filesystem boundaries and isolated runtime + state within the sandbox environments to restrict untrusted execution. +- **Prompt Injection Defense**: Prevented potential indirect prompt injection + vulnerabilities related to build file modifications and untrusted command + flags. +- **Flash Model ID Preservation**: Fixed core logic to properly preserve + explicit, versioned Flash model IDs without overriding them. +- **Agent Loop Reliability**: Resolved an issue where crucial `AgentLoopContext` + properties were discarded during object spread, stabilizing the main agent + loop. + +## What's Changed + +- Changelog for v0.60.0-preview.0 by @gemini-cli-robot in + [#29251](https://github.com/google-gemini/gemini-cli/pull/29251) +- chore(release): bump version to 0.61.0-nightly.20260908.gc647533d6 by + @gemini-cli-robot in + [#29254](https://github.com/google-gemini/gemini-cli/pull/29254) +- Changelog for v0.59.0 by @gemini-cli-robot in + [#29253](https://github.com/google-gemini/gemini-cli/pull/29253) +- fix(core): preserve explicit versioned Flash model IDs by @SandyTao520 in + [#29252](https://github.com/google-gemini/gemini-cli/pull/29252) +- fix(core): prevent indirect prompt injection via build file modifications and + untrusted flags by @villahernandez-coder in + [#29250](https://github.com/google-gemini/gemini-cli/pull/29250) +- fix(sandbox): harden filesystem boundaries and isolate runtime state by + @diegogodinezr in + [#29214](https://github.com/google-gemini/gemini-cli/pull/29214) +- fix(core): ensure AgentLoopContext properties are preserved across object + spread by @diegogodinezr in + [#29335](https://github.com/google-gemini/gemini-cli/pull/29335) + +**Full Changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.60.0-preview.0...v0.61.0-preview.0 diff --git a/docs/cli/acp-mode.md b/docs/cli/acp-mode.md new file mode 100644 index 0000000000000000000000000000000000000000..a5f9b6a63a3aa539cf35c5b58e3135a1540c19d4 --- /dev/null +++ b/docs/cli/acp-mode.md @@ -0,0 +1,126 @@ +# ACP Mode + +ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI +designed for programmatic control, primarily for IDE and other developer tool +integrations. It uses a JSON-RPC protocol over stdio to communicate between +Gemini CLI agent and a client. + +To start Gemini CLI in ACP mode, use the `--acp` flag: + +```bash +gemini --acp +``` + +## Agent Client Protocol (ACP) + +ACP is an open protocol that standardizes how AI coding agents communicate with +code editors and IDEs. It addresses the challenge of fragmented distribution, +where agents traditionally needed custom integrations for each client. With ACP, +developers can implement their agent once, and it becomes compatible with any +ACP-compliant editor. + +For a comprehensive introduction to ACP, including its architecture and +benefits, refer to the official +[ACP Introduction](https://agentclientprotocol.com/get-started/introduction) +documentation. + +### Existing integrations using ACP + +The ACP Agent Registry simplifies the distribution and management of +ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent +and can be found in this registry. + +For more general information about the registry, and how to use it with specific +IDEs like JetBrains and Zed, refer to the +[IDE Integration](../ide-integration/index.md) documentation. + +You can also find more information on the official +[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page. + +## Architecture and protocol basics + +ACP mode establishes a client-server relationship between your tool (the client) +and Gemini CLI (the server). + +- **Communication:** The entire communication happens over standard input/output + (stdio) using the JSON-RPC 2.0 protocol. +- **Client's role:** The client is responsible for sending requests (for + example, prompts) and handling responses and notifications from Gemini CLI. +- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC + requests, processes them, and sends back responses. + +The core of the ACP implementation can be found in +`packages/cli/src/acp/acpClient.ts`. + +### Extending with MCP + +ACP can be used with the Model Context Protocol (MCP). This lets an ACP client +(like an IDE) expose its own functionality as "tools" that the Gemini model can +use. + +1. The client implements an **MCP server** that advertises its tools. +2. During the ACP `initialize` handshake, the client provides the connection + details for its MCP server. +3. Gemini CLI connects to the MCP server, discovers the available tools, and + makes them available to the AI model. +4. When the model decides to use one of these tools, Gemini CLI sends a tool + call request to the MCP server. + +This mechanism lets for a powerful, two-way integration where the agent can +leverage the IDE's capabilities to perform tasks. The MCP client logic is in +`packages/core/src/tools/mcp-client.ts`. + +## Capabilities and supported methods + +The ACP protocol exposes a number of methods for ACP clients (for example IDEs) +to control Gemini CLI. + +### Core methods + +- `initialize`: Establishes the initial connection and lets the client to + register its MCP server. +- `authenticate`: Authenticates the user. +- `newSession`: Starts a new chat session. +- `loadSession`: Loads a previous session. +- `prompt`: Sends a prompt to the agent. +- `cancel`: Cancels an ongoing prompt. + +### Session control + +- `setSessionMode`: Allows changing the approval level for tool calls (for + example, to `auto-approve`). +- `unstable_setSessionModel`: Changes the model for the current session. + +### File system proxy + +ACP includes a proxied file system service. This means that when the agent needs +to read or write files, it does so through the ACP client. This is a security +feature that ensures the agent only has access to the files that the client (and +by extension, the user) has explicitly allowed. + +## Debugging and telemetry + +You can get insights into the ACP communication and the agent's behavior through +debugging logs and telemetry. + +### Debugging logs + +To enable general debugging logs, start Gemini CLI with the `--debug` flag: + +```bash +gemini --acp --debug +``` + +### Telemetry + +For more detailed telemetry, you can use the following environment variables to +capture telemetry data to a file: + +- `GEMINI_TELEMETRY_ENABLED=true` +- `GEMINI_TELEMETRY_TARGET=local` +- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json` + +This will write a JSON log file containing detailed information about all the +events happening within the agent, including ACP requests and responses. The +integration test `integration-tests/acp-telemetry.test.ts` provides a working +example of how to set this up. diff --git a/docs/cli/auto-memory.md b/docs/cli/auto-memory.md new file mode 100644 index 0000000000000000000000000000000000000000..d2a562d3943f7b2ac6a9fb6a89512bfea1343281 --- /dev/null +++ b/docs/cli/auto-memory.md @@ -0,0 +1,164 @@ +# Auto Memory + +Auto Memory is an experimental feature that mines your past Gemini CLI sessions +in the background and proposes durable memory updates and reusable +[Agent Skills](./skills.md). You review each candidate before it becomes +available to future sessions: apply memory updates, promote skills, or discard +anything you do not want. + + +> [!NOTE] +> This is an experimental feature currently under active development. + +## Overview + +Every session you run with Gemini CLI is recorded locally as a transcript. Auto +Memory scans those transcripts for durable facts, preferences, workflow +constraints, and procedural patterns that recur across sessions. It can draft +memory updates as unified diff `.patch` files and draft reusable procedures as +`SKILL.md` files. All candidates are held in a project-local inbox until you +approve or discard them. + +You'll use Auto Memory when you want to: + +- **Capture team workflows** that you find yourself walking the agent through + more than once. +- **Preserve durable project context** such as repeated verification commands, + local constraints, or personal project notes. +- **Codify hard-won fixes** for project-specific landmines so future sessions + avoid them. +- **Bootstrap a skills library** without writing every `SKILL.md` by hand. + +Auto Memory complements direct memory-file editing. The agent can still persist +explicit user instructions by editing the appropriate Markdown memory file; Auto +Memory infers candidates from past sessions, writes reviewable patches or skill +drafts, and never applies them without your approval. + +## Prerequisites + +- Gemini CLI installed and authenticated. +- At least one idle project session with 10 or more user messages. Auto Memory + ignores active, trivial, and sub-agent sessions. + +## How to enable Auto Memory + +Auto Memory is off by default. Enable it in your settings file: + +1. Open your global settings file at `~/.gemini/settings.json`. If you only + want Auto Memory in one project, edit `.gemini/settings.json` in that + project instead. + +2. Add the experimental flag: + + ```json + { + "experimental": { + "autoMemory": true + } + } + ``` + +3. Restart Gemini CLI. The flag requires a restart because the extraction + service starts during session boot. + +## How Auto Memory works + +Auto Memory runs as a background task on session startup. It does not block the +UI, consume your interactive turns, or surface tool prompts. + +1. **Eligibility scan.** The service indexes recent sessions from + `~/.gemini/tmp//chats/`. Sessions are eligible only if they have + been idle for at least three hours and contain at least 10 user messages. +2. **Lock acquisition.** A lock file in the project's memory directory + coordinates across multiple CLI instances so extraction runs at most once at + a time. A state file records processed session versions, and extraction is + throttled so short back-to-back CLI launches do not repeatedly scan history. +3. **Candidate extraction.** A background extraction agent reviews the session + index, reads any sessions that look like they contain durable memory or + repeated procedural workflows, and drafts candidates. It defaults to + creating no artifacts unless the evidence is strong, so many runs produce no + inbox items. +4. **Safety boundaries.** Auto Memory writes candidates to a review inbox. It + cannot directly edit active memory files, settings, credentials, or project + `GEMINI.md` files. +5. **Patch validation.** Skill update patches are parsed and dry-run before + they are surfaced. Memory patches are parsed, target-allowlisted, and + applied atomically only when you approve them from the inbox. +6. **Notification.** When a run produces new candidates, Gemini CLI surfaces an + inline message telling you how many items are waiting. + +## How to review extracted items + +Use the `/memory inbox` slash command to open the inbox dialog at any time: + +**Command:** `/memory inbox` + +The dialog groups pending items into new skills, skill updates, and memory +updates. From there you can: + +- **Read** the full `SKILL.md` body before deciding. +- **Promote** a skill to your user (`~/.gemini/skills/`) or workspace + (`.gemini/skills/`) directory. +- **Discard** a skill you do not want. +- **Apply** or reject a `.patch` proposal against an existing skill. +- **Review** memory diffs before they touch active files. +- **Apply** or dismiss private and global memory patches. Private patches target + the project memory directory; global patches target only your personal + `~/.gemini/GEMINI.md` file. + +Promoted skills become discoverable in the next session and follow the standard +[skill discovery precedence](./skills.md#skill-discovery-tiers). Applied memory +patches update the underlying memory files and reload memory for the current +session. + +## How to disable Auto Memory + +To turn off background extraction, set the flag back to `false` in your settings +file and restart Gemini CLI: + +```json +{ + "experimental": { + "autoMemory": false + } +} +``` + +Disabling the flag stops the background service immediately on the next session +start. Existing inbox items remain on disk; you can either drain them with +`/memory inbox` first or remove the project memory directory manually. + +## Data and privacy + +- Auto Memory only reads session files that already exist locally on your + machine. +- Auto Memory uses model calls to analyze selected local transcript content + during extraction. No candidates are applied automatically, but transcript + excerpts may be sent to the configured model as part of those calls. +- The extraction agent is instructed to redact secrets, tokens, and credentials + it encounters and to never copy large tool outputs verbatim. +- Drafted skills and memory patches live in your project's memory directory + until you promote, apply, dismiss, or discard them. They are not automatically + loaded into any session. + +## Limitations + +- The extraction agent runs on a preview Gemini Flash model. Extraction quality + depends on the model's ability to recognize durable patterns versus one-off + incidents. +- Auto Memory does not extract memory or skills from the current session. It + only considers sessions that have been idle for three hours or more. +- Project or workspace shared instructions in project `GEMINI.md` files are not + auto-extractable. Auto Memory can propose private project memory, global + personal memory, and skills. +- Inbox items are stored per project. Skills extracted in one workspace are not + visible from another until you promote them to the user-scope skills + directory. + +## Next steps + +- Learn how skills are discovered and activated in [Agent Skills](./skills.md). +- Explore the [memory management tutorial](./tutorials/memory-management.md) for + the complementary explicit-memory and `GEMINI.md` workflows. +- Review the experimental settings catalog in + [Settings](./settings.md#experimental). diff --git a/docs/cli/checkpointing.md b/docs/cli/checkpointing.md new file mode 100644 index 0000000000000000000000000000000000000000..775c9b7fea014122ad7f567be8a885c39c992761 --- /dev/null +++ b/docs/cli/checkpointing.md @@ -0,0 +1,95 @@ +# Checkpointing + +Gemini CLI includes a Checkpointing feature that automatically saves a snapshot +of your project's state before any file modifications are made by AI-powered +tools. This lets you safely experiment with and apply code changes, knowing you +can instantly revert back to the state before the tool was run. + +## How it works + +When you approve a tool that modifies the file system (like `write_file` or +`replace`), the CLI automatically creates a "checkpoint." This checkpoint +includes: + +1. **A Git snapshot:** A commit is made in a special, shadow Git repository + located in your home directory (`~/.gemini/history/`). This + snapshot captures the complete state of your project files at that moment. + It does **not** interfere with your own project's Git repository. +2. **Conversation history:** The entire conversation you've had with the agent + up to that point is saved. +3. **The tool call:** The specific tool call that was about to be executed is + also stored. + +If you want to undo the change or simply go back, you can use the `/restore` +command. Restoring a checkpoint will: + +- Revert all files in your project to the state captured in the snapshot. +- Restore the conversation history in the CLI. +- Re-propose the original tool call, allowing you to run it again, modify it, or + simply ignore it. + +All checkpoint data, including the Git snapshot and conversation history, is +stored locally on your machine. The Git snapshot is stored in the shadow +repository while the conversation history and tool calls are saved in a JSON +file in your project's temporary directory, typically located at +`~/.gemini/tmp//checkpoints`. + +## Enabling the feature + +The Checkpointing feature is disabled by default. To enable it, you need to edit +your `settings.json` file. + + +> [!CAUTION] +> The `--checkpointing` command-line flag was removed in version +> 0.11.0. Checkpointing can now only be enabled through the `settings.json` +> configuration file. + +Add the following key to your `settings.json`: + +```json +{ + "general": { + "checkpointing": { + "enabled": true + } + } +} +``` + +## Using the `/restore` command + +Once enabled, checkpoints are created automatically. To manage them, you use the +`/restore` command. + +### List available checkpoints + +To see a list of all saved checkpoints for the current project, simply run: + +``` +/restore +``` + +The CLI will display a list of available checkpoint files. These file names are +typically composed of a timestamp, the name of the file being modified, and the +name of the tool that was about to be run (for example, +`2025-06-22T10-00-00_000Z-my-file.txt-write_file`). + +### Restore a specific checkpoint + +To restore your project to a specific checkpoint, use the checkpoint file from +the list: + +``` +/restore +``` + +For example: + +``` +/restore 2025-06-22T10-00-00_000Z-my-file.txt-write_file +``` + +After running the command, your files and conversation will be immediately +restored to the state they were in when the checkpoint was created, and the +original tool prompt will reappear. diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md new file mode 100644 index 0000000000000000000000000000000000000000..a3d17c0a77897cd7a738e38410fdc6b43c65d282 --- /dev/null +++ b/docs/cli/cli-reference.md @@ -0,0 +1,134 @@ +# Gemini CLI cheatsheet + +This page provides a reference for commonly used Gemini CLI commands, options, +and parameters. + +## CLI commands + +| Command | Description | Example | +| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ | +| `gemini` | Start interactive REPL | `gemini` | +| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` | +| gemini "query" | Query and continue interactively | gemini "explain this project" | +| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` | +| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | +| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` | +| `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` | +| `gemini -r "" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` | +| `gemini update` | Update to latest version | `gemini update` | +| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) | +| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) | + +### Positional arguments + +| Argument | Type | Description | +| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | +| `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. | + +## Interactive commands + +These commands are available within the interactive REPL. + +| Command | Description | +| -------------------- | ----------------------------------------------- | +| `/skills reload` | Reload discovered skills from disk | +| `/agents reload` | Reload the agent registry | +| `/commands list` | List available custom slash commands | +| `/commands reload` | Reload custom slash commands | +| `/memory reload` | Reload context files (for example, `GEMINI.md`) | +| `/mcp reload` | Restart and reload MCP servers | +| `/extensions reload` | Reload all active extensions | +| `/help` | Show help for all commands | +| `/quit` | Exit the interactive session | + +## CLI Options + +| Option | Alias | Type | Default | Description | +| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | +| `--version` | `-v` | - | - | Show CLI version number and exit | +| `--help` | `-h` | - | - | Show help information | +| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | +| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | +| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. | +| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` | +| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | +| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | +| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | +| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | +| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | +| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | +| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | +| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (for example `--resume 5`) | +| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | +| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | +| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | +| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | +| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | + +## Model selection + +The `--model` (or `-m`) flag lets you specify which Gemini model to use. You can +use either model aliases (user-friendly names) or concrete model names. + +### Model aliases + +These are convenient shortcuts that map to specific models: + +| Alias | Resolves To | Description | +| ------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `auto` | `gemini-2.5-pro` or `gemini-3-pro-preview` | **Default.** Resolves to the preview model if preview features are enabled, otherwise resolves to the standard pro model. | +| `pro` | `gemini-2.5-pro` or `gemini-3-pro-preview` | For complex reasoning tasks. Uses preview model if enabled. | +| `flash` | `gemini-2.5-flash` | Fast, balanced model for most tasks. | +| `flash-lite` | `gemini-2.5-flash-lite` | Fastest model for simple tasks. | + +## Extensions management + +| Command | Description | Example | +| -------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | +| `gemini extensions install ` | Install extension from Git URL or local path | `gemini extensions install https://github.com/user/my-extension` | +| `gemini extensions install --ref ` | Install from specific branch/tag/commit | `gemini extensions install https://github.com/user/my-extension --ref develop` | +| `gemini extensions install --auto-update` | Install with auto-update enabled | `gemini extensions install https://github.com/user/my-extension --auto-update` | +| `gemini extensions uninstall ` | Uninstall one or more extensions | `gemini extensions uninstall my-extension` | +| `gemini extensions list` | List all installed extensions | `gemini extensions list` | +| `gemini extensions update ` | Update a specific extension | `gemini extensions update my-extension` | +| `gemini extensions update --all` | Update all extensions | `gemini extensions update --all` | +| `gemini extensions enable ` | Enable an extension | `gemini extensions enable my-extension` | +| `gemini extensions disable ` | Disable an extension | `gemini extensions disable my-extension` | +| `gemini extensions link ` | Link local extension for development | `gemini extensions link /path/to/extension` | +| `gemini extensions new ` | Create new extension from template | `gemini extensions new ./my-extension` | +| `gemini extensions validate ` | Validate extension structure | `gemini extensions validate ./my-extension` | + +See [Extensions Documentation](../extensions/index.md) for more details. + +## MCP server management + +| Command | Description | Example | +| ------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `gemini mcp add ` | Add stdio-based MCP server | `gemini mcp add github npx -y @modelcontextprotocol/server-github` | +| `gemini mcp add --transport http` | Add HTTP-based MCP server | `gemini mcp add api-server http://localhost:3000 --transport http` | +| `gemini mcp add --env KEY=value` | Add with environment variables | `gemini mcp add slack node server.js --env SLACK_TOKEN=xoxb-xxx` | +| `gemini mcp add --scope user` | Add with user scope | `gemini mcp add db node db-server.js --scope user` | +| `gemini mcp add --include-tools tool1,tool2` | Add with specific tools | `gemini mcp add github npx -y @modelcontextprotocol/server-github --include-tools list_repos,get_pr` | +| `gemini mcp remove ` | Remove an MCP server | `gemini mcp remove github` | +| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` | + +See [MCP Server Integration](../tools/mcp-server.md) for more details. + +## Skills management + +| Command | Description | Example | +| -------------------------------- | ------------------------------------- | ------------------------------------------------- | +| `gemini skills list` | List all discovered agent skills | `gemini skills list` | +| `gemini skills install ` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` | +| `gemini skills link ` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` | +| `gemini skills uninstall ` | Uninstall an agent skill | `gemini skills uninstall my-skill` | +| `gemini skills enable ` | Enable an agent skill | `gemini skills enable my-skill` | +| `gemini skills disable ` | Disable an agent skill | `gemini skills disable my-skill` | +| `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` | +| `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` | + +See [Agent Skills Documentation](./skills.md) for more details. diff --git a/docs/cli/creating-skills.md b/docs/cli/creating-skills.md new file mode 100644 index 0000000000000000000000000000000000000000..ea9fa86a6c1f8b55ea861f7b31cde9b3efd0c44d --- /dev/null +++ b/docs/cli/creating-skills.md @@ -0,0 +1,207 @@ +# Creating Agent Skills + +Agent Skills let you extend Gemini CLI with specialized expertise, procedural +workflows, and task-specific resources. This guide walks you through both +automated and manual methods for creating and organizing your skills. + +## Quickstart: Create a skill with a prompt + +The fastest way to create a new skill is to use the built-in `skill-creator`. +This meta-skill guides you through designing, scaffolding, and validating your +expertise. + +Simply ask Gemini CLI to create a skill for you: + +> "Create a new skill called 'code-reviewer' that analyzes local files for +> common errors and style violations." + +Gemini will then: + +1. Generate a new directory for your skill (for example, `my-new-skill/`). +2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and + `description`). +3. Create the standard resource directories: `scripts/`, `references/`, and + `assets/`. + +Once created, you can find your new skill in `.gemini/skills/code-reviewer/`. + +## Manual creation + +1. **Create a directory** for your skill (for example, `my-new-skill/`). +2. **Create a `SKILL.md` file** inside the new directory. + +### 1. Create the directory structure + +The first step is to create the necessary folders for your skill and its +scripts. + +**macOS/Linux** + +```bash +mkdir -p .gemini/skills/code-reviewer/scripts +``` + +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path ".gemini\skills\code-reviewer\scripts" +``` + +### 2. Define the skill (`SKILL.md`) + +The `SKILL.md` file defines the skill's purpose and instructions for the agent. +Create a file at `.gemini/skills/code-reviewer/SKILL.md`. + +```markdown +--- +name: code-reviewer +description: + Expertise in reviewing code changes for correctness, security, and style. Use + when the user asks to "review" their code or a PR. +--- + +# Code Reviewer Instructions + +You act as a senior software engineer specialized in code quality. When this +skill is active, you MUST: + +1. **Analyze**: Review the provided code for logical errors, security + vulnerabilities, and style violations. +2. **Review**: Use the bundled `scripts/review.js` utility to perform an + automated check. +3. **Feedback**: Provide constructive feedback, clearly distinguishing between + critical issues and minor improvements. +``` + +### 3. Add the tool logic + +Skills can bundle resources like scripts to perform deterministic tasks. Create +a file at `.gemini/skills/code-reviewer/scripts/review.js`. + +```javascript +// .gemini/skills/code-reviewer/scripts/review.js +const file = process.argv[2]; + +if (!file) { + console.error('Usage: node review.js '); + process.exit(1); +} + +console.log(`Reviewing ${file}...`); +// Simple mock review logic +setTimeout(() => { + console.log(`Result: Success (No major issues found in ${file})`); +}, 500); +``` + +### 4. Test the skill + +Gemini CLI automatically discovers skills in the `.gemini/skills` directory. + +1. Start a new session and ask a question that triggers the skill's + description: "Can you review index.js" +2. Gemini identifies the request matches the `code-reviewer` description and + asks for permission to activate it. +3. Once you approve, Gemini executes the bundled script: + `node .gemini/skills/code-reviewer/scripts/review.js index.js` + +To determine whether your skill has been correctly loaded, run the command: + +```bash +/skills +``` + +### 5. Optional: Share your skill + +You can share your skills in several ways depending on your target audience. + +- **Workspace skills**: Commit your skill to a `.gemini/skills/` directory in + your project repository. +- **Extensions**: Bundle your skill within a + [Gemini CLI extension](../extensions/writing-extensions.md). +- **Git repositories**: Share the skill directory as a standalone Git repo and + install it using `gemini skills install `. + +--- + +## Core concepts + +Now that you've built your first skill, let's explore the core components and +workflows for developing more complex expertise. + +### Skill structure + +While a `SKILL.md` file is the only required component, we recommend the +following structure for organizing your skill's resources. + +```text +my-skill/ +β”œβ”€β”€ SKILL.md (Required) Instructions and metadata +β”œβ”€β”€ scripts/ (Optional) Executable scripts +β”œβ”€β”€ references/ (Optional) Static documentation +└── assets/ (Optional) Templates and other resources +``` + +When a skill is activated, the model is granted access to this entire directory. +You can instruct the model to use the tools and files found within these +folders. + +### Metadata and triggers + +The `SKILL.md` file uses YAML frontmatter for metadata. + +- **`name`**: A unique identifier for the skill. This should match the directory + name. +- **`description`**: **CRITICAL.** This is how Gemini decides when to use the + skill. Be specific about the tasks it handles and the keywords that should + trigger it. + +### Discovery tiers + +Gemini CLI discovers skills from several locations, following a specific order +of precedence (lowest to highest): + +1. **Built-in Skills**: Included with Gemini CLI (pre-approved). +2. **Extension Skills**: Bundled within [extensions](../extensions/). +3. **User Skills**: `~/.gemini/skills/` or the `~/.agents/skills/` alias. +4. **Workspace Skills**: `.gemini/skills/` or the `.agents/skills/` alias. + +### Discovery aliases + +You can use `.agents/skills` as an alternative to `.gemini/skills`. This alias +is compatible with other AI agent tools following the +[Agent Skills](https://agentskills.io) standard. + +## Advanced development + +Once you've built a basic skill, you can use specialized scripts and workflows +to streamline your development process. + +### Creation scripts + +If you are developing a skill and want to use the same scripts the built-in +tools use, you can find them in the core package. These scripts help automate +the initialization, validation, and packaging of skills. + +- **Initialize**: `node scripts/init_skill.cjs --path ` +- **Validate**: `node scripts/validate_skill.cjs ` +- **Package**: `node scripts/package_skill.cjs ` (Creates a + `.skill` zip file) + +### Linking for local development + +If you are developing a skill in a separate directory, you can link it to your +user skills directory for testing: + +```bash +gemini skills link . +``` + +## Next steps + +- [Skill best practices](./skills-best-practices.md): Learn strategies for + building reliable and effective skills. +- [Agent Skills overview](./skills.md): Deep dive into discovery tiers and the + skill lifecycle. +- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A + quick walkthrough of triggering and using skills. diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md new file mode 100644 index 0000000000000000000000000000000000000000..3b431fca668dd9ddec81e6cae936124e62d0abf7 --- /dev/null +++ b/docs/cli/custom-commands.md @@ -0,0 +1,331 @@ +# Custom commands + +Custom commands let you save and reuse your favorite or most frequently used +prompts as personal shortcuts within Gemini CLI. You can create commands that +are specific to a single project or commands that are available globally across +all your projects, streamlining your workflow and ensuring consistency. + +## File locations and precedence + +Gemini CLI discovers commands from two locations, loaded in a specific order: + +1. **User commands (global):** Located in `~/.gemini/commands/`. These commands + are available in any project you are working on. +2. **Project commands (local):** Located in + `/.gemini/commands/`. These commands are specific to the + current project and can be checked into version control to be shared with + your team. + +If a command in the project directory has the same name as a command in the user +directory, the **project command will always be used.** This allows projects to +override global commands with project-specific versions. + +## Naming and namespacing + +The name of a command is determined by its file path relative to its `commands` +directory. Subdirectories are used to create namespaced commands, with the path +separator (`/` or `\`) being converted to a colon (`:`). + +- A file at `~/.gemini/commands/test.toml` becomes the command `/test`. +- A file at `/.gemini/commands/git/commit.toml` becomes the namespaced + command `/git:commit`. + + +> [!TIP] +> After creating or modifying `.toml` command files, run +> `/commands reload` to pick up your changes without restarting the CLI. +> To see all available command files, run `/commands list`. + +## TOML file format (v1) + +Your command definition files must be written in the TOML format and use the +`.toml` file extension. + +### Required fields + +- `prompt` (String): The prompt that will be sent to the Gemini model when the + command is executed. This can be a single-line or multi-line string. + +### Optional fields + +- `description` (String): A brief, one-line description of what the command + does. This text will be displayed next to your command in the `/help` menu. + **If you omit this field, a generic description will be generated from the + filename.** + +## Handling arguments + +Custom commands support two powerful methods for handling arguments. The CLI +automatically chooses the correct method based on the content of your command's +`prompt`. + +### 1. Context-aware injection with `{{args}}` + +If your `prompt` contains the special placeholder `{{args}}`, the CLI will +replace that placeholder with the text the user typed after the command name. + +The behavior of this injection depends on where it is used: + +**A. Raw injection (outside shell commands)** + +When used in the main body of the prompt, the arguments are injected exactly as +the user typed them. + +**Example (`git/fix.toml`):** + +```toml +# Invoked via: /git:fix "Button is misaligned" + +description = "Generates a fix for a given issue." +prompt = "Please provide a code fix for the issue described here: {{args}}." +``` + +The model receives: +`Please provide a code fix for the issue described here: "Button is misaligned".` + +**B. Using arguments in shell commands (inside `!{...}` blocks)** + +When you use `{{args}}` inside a shell injection block (`!{...}`), the arguments +are automatically **shell-escaped** before replacement. This lets you safely +pass arguments to shell commands, ensuring the resulting command is +syntactically correct and secure while preventing command injection +vulnerabilities. + +**Example (`/grep-code.toml`):** + +```toml +prompt = """ +Please summarize the findings for the pattern `{{args}}`. + +Search Results: +!{grep -r {{args}} .} +""" +``` + +When you run `/grep-code It's complicated`: + +1. The CLI sees `{{args}}` used both outside and inside `!{...}`. +2. Outside: The first `{{args}}` is replaced raw with `It's complicated`. +3. Inside: The second `{{args}}` is replaced with the escaped version (for + example, on Linux: `"It\'s complicated"`). +4. The command executed is `grep -r "It's complicated" .`. +5. The CLI prompts you to confirm this exact, secure command before execution. +6. The final prompt is sent. + +### 2. Default argument handling + +If your `prompt` does **not** contain the special placeholder `{{args}}`, the +CLI uses a default behavior for handling arguments. + +If you provide arguments to the command (for example, `/mycommand arg1`), the +CLI will append the full command you typed to the end of the prompt, separated +by two newlines. This allows the model to see both the original instructions and +the specific arguments you just provided. + +If you do **not** provide any arguments (for example, `/mycommand`), the prompt +is sent to the model exactly as it is, with nothing appended. + +**Example (`changelog.toml`):** + +This example shows how to create a robust command by defining a role for the +model, explaining where to find the user's input, and specifying the expected +format and behavior. + +```toml +# In: /.gemini/commands/changelog.toml +# Invoked via: /changelog 1.2.0 added "Support for default argument parsing." + +description = "Adds a new entry to the project's CHANGELOG.md file." +prompt = """ +# Task: Update Changelog + +You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog. + +**The user's raw command is appended below your instructions.** + +Your task is to parse the ``, ``, and `` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file. + +## Expected Format +The command follows this format: `/changelog ` +- `` must be one of: "added", "changed", "fixed", "removed". + +## Behavior +1. Read the `CHANGELOG.md` file. +2. Find the section for the specified ``. +3. Add the `` under the correct `` heading. +4. If the version or type section doesn't exist, create it. +5. Adhere strictly to the "Keep a Changelog" format. +""" +``` + +When you run `/changelog 1.2.0 added "New feature"`, the final text sent to the +model will be the original prompt followed by two newlines and the command you +typed. + +### 3. Executing shell commands with `!{...}` + +You can make your commands dynamic by executing shell commands directly within +your `prompt` and injecting their output. This is ideal for gathering context +from your local environment, like reading file content or checking the status of +Git. + +When a custom command attempts to execute a shell command, Gemini CLI will now +prompt you for confirmation before proceeding. This is a security measure to +ensure that only intended commands can be run. + +**How it works:** + +1. **Inject commands:** Use the `!{...}` syntax. +2. **Argument substitution:** If `{{args}}` is present inside the block, it is + automatically shell-escaped (see + [Context-Aware Injection](#1-context-aware-injection-with-args) above). +3. **Robust parsing:** The parser correctly handles complex shell commands that + include nested braces, such as JSON payloads. The content inside `!{...}` + must have balanced braces (`{` and `}`). If you need to execute a command + containing unbalanced braces, consider wrapping it in an external script + file and calling the script within the `!{...}` block. +4. **Security check and confirmation:** The CLI performs a security check on + the final, resolved command (after arguments are escaped and substituted). A + dialog will appear showing the exact command(s) to be executed. +5. **Execution and error reporting:** The command is executed. If the command + fails, the output injected into the prompt will include the error messages + (stderr) followed by a status line, for example, + `[Shell command exited with code 1]`. This helps the model understand the + context of the failure. + +**Example (`git/commit.toml`):** + +This command gets the staged git diff and uses it to ask the model to write a +commit message. + +````toml +# In: /.gemini/commands/git/commit.toml +# Invoked via: /git:commit + +description = "Generates a Git commit message based on staged changes." + +# The prompt uses !{...} to execute the command and inject its output. +prompt = """ +Please generate a Conventional Commit message based on the following git diff: + +```diff +!{git diff --staged} +``` + +""" + +```` + +When you run `/git:commit`, the CLI first executes `git diff --staged`, then +replaces `!{git diff --staged}` with the output of that command before sending +the final, complete prompt to the model. + +### 4. Injecting file content with `@{...}` + +You can directly embed the content of a file or a directory listing into your +prompt using the `@{...}` syntax. This is useful for creating commands that +operate on specific files. + +**How it works:** + +- **File injection**: `@{path/to/file.txt}` is replaced by the content of + `file.txt`. +- **Multimodal support**: If the path points to a supported image (for example, + PNG, JPEG), PDF, audio, or video file, it will be correctly encoded and + injected as multimodal input. Other binary files are handled gracefully and + skipped. +- **Directory listing**: `@{path/to/dir}` is traversed and each file present + within the directory and all subdirectories is inserted into the prompt. This + respects `.gitignore` and `.geminiignore` if enabled. +- **Workspace-aware**: The command searches for the path in the current + directory and any other workspace directories. Absolute paths are allowed if + they are within the workspace. +- **Processing order**: File content injection with `@{...}` is processed + _before_ shell commands (`!{...}`) and argument substitution (`{{args}}`). +- **Parsing**: The parser requires the content inside `@{...}` (the path) to + have balanced braces (`{` and `}`). + +**Example (`review.toml`):** + +This command injects the content of a _fixed_ best practices file +(`docs/best-practices.md`) and uses the user's arguments to provide context for +the review. + +```toml +# In: /.gemini/commands/review.toml +# Invoked via: /review FileCommandLoader.ts + +description = "Reviews the provided context using a best practice guide." +prompt = """ +You are an expert code reviewer. + +Your task is to review {{args}}. + +Use the following best practices when providing your review: + +@{docs/best-practices.md} +""" +``` + +When you run `/review FileCommandLoader.ts`, the `@{docs/best-practices.md}` +placeholder is replaced by the content of that file, and `{{args}}` is replaced +by the text you provided, before the final prompt is sent to the model. + +--- + +## Example: A "Pure Function" refactoring command + +Let's create a global command that asks the model to refactor a piece of code. + +**1. Create the file and directories:** + +First, ensure the user commands directory exists, then create a `refactor` +subdirectory for organization and the final TOML file. + +**macOS/Linux** + +```bash +mkdir -p ~/.gemini/commands/refactor +touch ~/.gemini/commands/refactor/pure.toml +``` + +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\commands\refactor" +New-Item -ItemType File -Force -Path "$env:USERPROFILE\.gemini\commands\refactor\pure.toml" +``` + +**2. Add the content to the file:** + +Open `~/.gemini/commands/refactor/pure.toml` in your editor and add the +following content. We are including the optional `description` for best +practice. + +```toml +# In: ~/.gemini/commands/refactor/pure.toml +# This command will be invoked via: /refactor:pure + +description = "Asks the model to refactor the current context into a pure function." + +prompt = """ +Please analyze the code I've provided in the current context. +Refactor it into a pure function. + +Your response should include: +1. The refactored, pure function code block. +2. A brief explanation of the key changes you made and why they contribute to purity. +""" +``` + +**3. Run the command:** + +That's it! You can now run your command in the CLI. First, you might add a file +to the context, and then invoke your command: + +``` +> @my-messy-function.js +> /refactor:pure +``` + +Gemini CLI will then execute the multi-line prompt defined in your TOML file. diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md new file mode 100644 index 0000000000000000000000000000000000000000..215d2ee55779dfcb7c10ff1dc8ff27cc8169007f --- /dev/null +++ b/docs/cli/enterprise.md @@ -0,0 +1,613 @@ +# Gemini CLI for the enterprise + +This document outlines configuration patterns and best practices for deploying +and managing Gemini CLI in an enterprise environment. By leveraging system-level +settings, administrators can enforce security policies, manage tool access, and +ensure a consistent experience for all users. + + +> [!WARNING] +> The patterns described in this document are intended to help +> administrators create a more controlled and secure environment for using +> Gemini CLI. However, they should not be considered a foolproof security +> boundary. A determined user with sufficient privileges on their local machine +> may still be able to circumvent these configurations. These measures are +> designed to prevent accidental misuse and enforce corporate policy in a +> managed environment, not to defend against a malicious actor with local +> administrative rights. + +## Centralized configuration: The system settings file + +The most powerful tools for enterprise administration are the system-wide +settings files. These files allow you to define a baseline configuration +(`system-defaults.json`) and a set of overrides (`settings.json`) that apply to +all users on a machine. For a complete overview of configuration options, see +the [Configuration documentation](../reference/configuration.md). + +Settings are merged from four files. The precedence order for single-value +settings (like `theme`) is: + +1. System Defaults (`system-defaults.json`) +2. User Settings (`~/.gemini/settings.json`) +3. Workspace Settings (`/.gemini/settings.json`) +4. System Overrides (`settings.json`) + +This means the System Overrides file has the final say. For settings that are +arrays (`includeDirectories`) or objects (`mcpServers`), the values are merged. + +**Example of merging and precedence:** + +Here is how settings from different levels are combined. + +- **System defaults `system-defaults.json`:** + + ```json + { + "ui": { + "theme": "default-corporate-theme" + }, + "context": { + "includeDirectories": ["/etc/gemini-cli/common-context"] + } + } + ``` + +- **User `settings.json` (`~/.gemini/settings.json`):** + + ```json + { + "ui": { + "theme": "user-preferred-dark-theme" + }, + "mcpServers": { + "corp-server": { + "command": "/usr/local/bin/corp-server-dev" + }, + "user-tool": { + "command": "npm start --prefix ~/tools/my-tool" + } + }, + "context": { + "includeDirectories": ["~/gemini-context"] + } + } + ``` + +- **Workspace `settings.json` (`/.gemini/settings.json`):** + + ```json + { + "ui": { + "theme": "project-specific-light-theme" + }, + "mcpServers": { + "project-tool": { + "command": "npm start" + } + }, + "context": { + "includeDirectories": ["./project-context"] + } + } + ``` + +- **System overrides `settings.json`:** + ```json + { + "ui": { + "theme": "system-enforced-theme" + }, + "mcpServers": { + "corp-server": { + "command": "/usr/local/bin/corp-server-prod" + } + }, + "context": { + "includeDirectories": ["/etc/gemini-cli/global-context"] + } + } + ``` + +This results in the following merged configuration: + +- **Final merged configuration:** + ```json + { + "ui": { + "theme": "system-enforced-theme" + }, + "mcpServers": { + "corp-server": { + "command": "/usr/local/bin/corp-server-prod" + }, + "user-tool": { + "command": "npm start --prefix ~/tools/my-tool" + }, + "project-tool": { + "command": "npm start" + } + }, + "context": { + "includeDirectories": [ + "/etc/gemini-cli/common-context", + "~/gemini-context", + "./project-context", + "/etc/gemini-cli/global-context" + ] + } + } + ``` + +**Why:** + +- **`theme`**: The value from the system overrides (`system-enforced-theme`) is + used, as it has the highest precedence. +- **`mcpServers`**: The objects are merged. The `corp-server` definition from + the system overrides takes precedence over the user's definition. The unique + `user-tool` and `project-tool` are included. +- **`includeDirectories`**: The arrays are concatenated in the order of System + Defaults, User, Workspace, and then System Overrides. + +- **Location**: + - **Linux**: `/etc/gemini-cli/settings.json` + - **Windows**: `C:\ProgramData\gemini-cli\settings.json` + - **macOS**: `/Library/Application Support/GeminiCli/settings.json` + - The path can be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` + environment variable. +- **Control**: This file should be managed by system administrators and + protected with appropriate file permissions to prevent unauthorized + modification by users. + +By using the system settings file, you can enforce the security and +configuration patterns described below. + +### Enforcing system settings with a wrapper script + +While the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable provides +flexibility, a user could potentially override it to point to a different +settings file, bypassing the centrally managed configuration. To mitigate this, +enterprises can deploy a wrapper script or alias that ensures the environment +variable is always set to the corporate-controlled path. + +This approach ensures that no matter how the user calls the `gemini` command, +the enterprise settings are always loaded with the highest precedence. + +**Example wrapper script:** + +Administrators can create a script named `gemini` and place it in a directory +that appears earlier in the user's `PATH` than the actual Gemini CLI binary (for +example, `/usr/local/bin/gemini`). + +```bash +#!/bin/bash + +# Enforce the path to the corporate system settings file. +# This ensures that the company's configuration is always applied. +export GEMINI_CLI_SYSTEM_SETTINGS_PATH="/etc/gemini-cli/settings.json" + +# Find the original gemini executable. +# This is a simple example; a more robust solution might be needed +# depending on the installation method. +REAL_GEMINI_PATH=$(type -aP gemini | grep -v "^$(type -P gemini)$" | head -n 1) + +if [ -z "$REAL_GEMINI_PATH" ]; then + echo "Error: The original 'gemini' executable was not found." >&2 + exit 1 +fi + +# Pass all arguments to the real Gemini CLI executable. +exec "$REAL_GEMINI_PATH" "$@" +``` + +By deploying this script, the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` is set within +the script's environment, and the `exec` command replaces the script process +with the actual Gemini CLI process, which inherits the environment variable. +This makes it significantly more difficult for a user to bypass the enforced +settings. + +**PowerShell Profile (Windows alternative):** + +On Windows, administrators can achieve similar results by adding the environment +variable to the system-wide or user-specific PowerShell profile: + +```powershell +Add-Content -Path $PROFILE -Value '$env:GEMINI_CLI_SYSTEM_SETTINGS_PATH="C:\ProgramData\gemini-cli\settings.json"' +``` + +## User isolation in shared environments + +In shared compute environments (like ML experiment runners or shared build +servers), you can isolate Gemini CLI state by overriding the user's home +directory. + +By default, Gemini CLI stores configuration and history in `~/.gemini`. You can +use the `GEMINI_CLI_HOME` environment variable to point to a unique directory +for a specific user or job. The CLI will create a `.gemini` folder inside the +specified path. + +**macOS/Linux** + +```bash +# Isolate state for a specific job +export GEMINI_CLI_HOME="/tmp/gemini-job-123" +gemini +``` + +**Windows (PowerShell)** + +```powershell +# Isolate state for a specific job +$env:GEMINI_CLI_HOME="C:\temp\gemini-job-123" +gemini +``` + +## Restricting tool access + +You can significantly enhance security by controlling which tools the Gemini +model can use. This is achieved through the `tools.core` setting and the +[Policy Engine](../reference/policy-engine.md). For a list of available tools, +see the [Tools reference](../reference/tools.md). + +### Allowlisting with `coreTools` + +The most secure approach is to explicitly add the tools and commands that users +are permitted to execute to an allowlist. This prevents the use of any tool not +on the approved list. + +**Example:** Allow only safe, read-only file operations and listing files. + +```json +{ + "tools": { + "core": ["ReadFileTool", "GlobTool", "ShellTool(ls)"] + } +} +``` + +### Blocklisting with `excludeTools` (Deprecated) + +> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for +> more robust control. + +Alternatively, you can add specific tools that are considered dangerous in your +environment to a blocklist. + +**Example:** Prevent the use of the shell tool for removing files. + +```json +{ + "tools": { + "exclude": ["ShellTool(rm -rf)"] + } +} +``` + + +> [!WARNING] +> Blocklisting with `excludeTools` is less secure than +> allowlisting with `tools.core`, as it relies on blocking known-bad commands, +> and clever users may find ways to bypass simple string-based blocks. +> **Allowlisting is the recommended approach.** + +### Disabling YOLO mode + +To ensure that users cannot bypass the confirmation prompt for tool execution, +you can disable YOLO mode at the policy level. This adds a critical layer of +safety, as it prevents the model from executing tools without explicit user +approval. + +**Example:** Force all tool executions to require user confirmation. + +```json +{ + "security": { + "disableYoloMode": true + } +} +``` + +This setting is highly recommended in an enterprise environment to prevent +unintended tool execution. + +## Managing custom tools (MCP servers) + +If your organization uses custom tools via +[Model-Context Protocol (MCP) servers](../tools/mcp-server.md), it is crucial to +understand how server configurations are managed to apply security policies +effectively. + +### How MCP server configurations are merged + +Gemini CLI loads `settings.json` files from three levels: System, Workspace, and +User. When it comes to the `mcpServers` object, these configurations are +**merged**: + +1. **Merging:** The lists of servers from all three levels are combined into a + single list. +2. **Precedence:** If a server with the **same name** is defined at multiple + levels (for example, a server named `corp-api` exists in both system and + user settings), the definition from the highest-precedence level is used. + The order of precedence is: **System > Workspace > User**. + +This means a user **cannot** override the definition of a server that is already +defined in the system-level settings. However, they **can** add new servers with +unique names. + +### Enforcing a catalog of tools + +The security of your MCP tool ecosystem depends on a combination of defining the +canonical servers and adding their names to an allowlist. + +### Restricting tools within an MCP server + +For even greater security, especially when dealing with third-party MCP servers, +you can restrict which specific tools from a server are exposed to the model. +This is done using the `includeTools` and `excludeTools` properties within a +server's definition. This lets you use a subset of tools from a server without +allowing potentially dangerous ones. + +Following the principle of least privilege, it is highly recommended to use +`includeTools` to create an allowlist of only the necessary tools. + +**Example:** Only allow the `code-search` and `get-ticket-details` tools from a +third-party MCP server, even if the server offers other tools like +`delete-ticket`. + +```json +{ + "mcp": { + "allowed": ["third-party-analyzer"] + }, + "mcpServers": { + "third-party-analyzer": { + "command": "/usr/local/bin/start-3p-analyzer.sh", + "includeTools": ["code-search", "get-ticket-details"] + } + } +} +``` + +#### More secure pattern: Define and add to allowlist in system settings + +To create a secure, centrally-managed catalog of tools, the system administrator +**must** do both of the following in the system-level `settings.json` file: + +1. **Define the full configuration** for every approved server in the + `mcpServers` object. This ensures that even if a user defines a server with + the same name, the secure system-level definition will take precedence. +2. **Add the names** of those servers to an allowlist using the `mcp.allowed` + setting. This is a critical security step that prevents users from running + any servers that are not on this list. If this setting is omitted, the CLI + will merge and allow any server defined by the user. + +**Example system `settings.json`:** + +1. Add the _names_ of all approved servers to an allowlist. This will prevent + users from adding their own servers. + +2. Provide the canonical _definition_ for each server on the allowlist. + +```json +{ + "mcp": { + "allowed": ["corp-data-api", "source-code-analyzer"] + }, + "mcpServers": { + "corp-data-api": { + "command": "/usr/local/bin/start-corp-api.sh", + "timeout": 5000 + }, + "source-code-analyzer": { + "command": "/usr/local/bin/start-analyzer.sh" + } + } +} +``` + +This pattern is more secure because it uses both definition and an allowlist. +Any server a user defines will either be overridden by the system definition (if +it has the same name) or blocked because its name is not in the `mcp.allowed` +list. + +### Less secure pattern: Omitting the allowlist + +If the administrator defines the `mcpServers` object but fails to also specify +the `mcp.allowed` allowlist, users may add their own servers. + +**Example system `settings.json`:** + +This configuration defines servers but does not enforce the allowlist. The +administrator has NOT included the "mcp.allowed" setting. + +```json +{ + "mcpServers": { + "corp-data-api": { + "command": "/usr/local/bin/start-corp-api.sh" + } + } +} +``` + +In this scenario, a user can add their own server in their local +`settings.json`. Because there is no `mcp.allowed` list to filter the merged +results, the user's server will be added to the list of available tools and +allowed to run. + +## Enforcing sandboxing for security + +To mitigate the risk of potentially harmful operations, you can enforce the use +of sandboxing for all tool execution. The sandbox isolates tool execution in a +containerized environment. + +**Example:** Force all tool execution to happen within a Docker sandbox. + +```json +{ + "tools": { + "sandbox": "docker" + } +} +``` + +You can also specify a custom, hardened Docker image for the sandbox by building +a custom `sandbox.Dockerfile` as described in the +[Sandboxing documentation](./sandbox.md). + +## Controlling network access via proxy + +In corporate environments with strict network policies, you can configure Gemini +CLI to route all outbound traffic through a corporate proxy. This can be set via +an environment variable, but it can also be enforced for custom tools via the +`mcpServers` configuration. + +**Example (for an MCP server):** + +```json +{ + "mcpServers": { + "proxied-server": { + "command": "node", + "args": ["mcp_server.js"], + "env": { + "HTTP_PROXY": "http://proxy.example.com:8080", + "HTTPS_PROXY": "http://proxy.example.com:8080" + } + } + } +} +``` + +## Telemetry and auditing + +For auditing and monitoring purposes, you can configure Gemini CLI to send +telemetry data to a central location. This lets you track tool usage and other +events. For more information, see the [telemetry documentation](./telemetry.md). + +**Example:** Enable telemetry and send it to a local OTLP collector. If +`otlpEndpoint` is not specified, it defaults to `http://localhost:4317`. + +```json +{ + "telemetry": { + "enabled": true, + "target": "gcp", + "logPrompts": false + } +} +``` + + +> [!NOTE] +> Ensure that `logPrompts` is set to `false` in an enterprise setting to +> avoid collecting potentially sensitive information from user prompts. + +## Authentication + +You can enforce a specific authentication method for all users by setting the +`security.auth.enforcedType` in the system-level `settings.json` file. This +prevents users from choosing a different authentication method. See the +[Authentication docs](../get-started/authentication.mdx) for more details. + +**Example:** Enforce the use of Google login for all users. + +```json +{ + "security": { + "auth": { + "enforcedType": "oauth-personal" + } + } +} +``` + +If a user has a different authentication method configured, they will be +prompted to switch to the enforced method. In non-interactive mode, the CLI will +exit with an error if the configured authentication method does not match the +enforced one. + +### Restricting logins to corporate domains + +For enterprises using Google Workspace, you can enforce that users only +authenticate with their corporate Google accounts. This is a network-level +control that is configured on a proxy server, not within Gemini CLI itself. It +works by intercepting authentication requests to Google and adding a special +HTTP header. + +This policy prevents users from logging in with personal Gmail accounts or other +non-corporate Google accounts. + +For detailed instructions, see the Google Workspace Admin Help article on +[blocking access to consumer accounts](https://support.google.com/a/answer/1668854?hl=en#zippy=%2Cstep-choose-a-web-proxy-server%2Cstep-configure-the-network-to-block-certain-accounts). + +The general steps are as follows: + +1. **Intercept Requests**: Configure your web proxy to intercept all requests + to `google.com`. +2. **Add HTTP Header**: For each intercepted request, add the + `X-GoogApps-Allowed-Domains` HTTP header. +3. **Specify Domains**: The value of the header should be a comma-separated + list of your approved Google Workspace domain names. + +**Example header:** + +``` +X-GoogApps-Allowed-Domains: my-corporate-domain.com, secondary-domain.com +``` + +When this header is present, Google's authentication service will only allow +logins from accounts belonging to the specified domains. + +## Putting it all together: example system `settings.json` + +Here is an example of a system `settings.json` file that combines several of the +patterns discussed above to create a secure, controlled environment for Gemini +CLI. + +```json +{ + "tools": { + "sandbox": "docker", + "core": [ + "ReadFileTool", + "GlobTool", + "ShellTool(ls)", + "ShellTool(cat)", + "ShellTool(grep)" + ] + }, + "mcp": { + "allowed": ["corp-tools"] + }, + "mcpServers": { + "corp-tools": { + "command": "/opt/gemini-tools/start.sh", + "timeout": 5000 + } + }, + "telemetry": { + "enabled": true, + "target": "gcp", + "otlpEndpoint": "https://telemetry-prod.example.com:4317", + "logPrompts": false + }, + "advanced": { + "bugCommand": { + "urlTemplate": "https://servicedesk.example.com/new-ticket?title={title}&details={info}" + } + }, + "privacy": { + "usageStatisticsEnabled": false + } +} +``` + +This configuration: + +- Forces all tool execution into a Docker sandbox. +- Strictly uses an allowlist for a small set of safe shell commands and file + tools. +- Defines and allows a single corporate MCP server for custom tools. +- Enables telemetry for auditing, without logging prompt content. +- Redirects the `/bug` command to an internal ticketing system. +- Disables general usage statistics collection. diff --git a/docs/cli/gemini-ignore.md b/docs/cli/gemini-ignore.md new file mode 100644 index 0000000000000000000000000000000000000000..fcdf94482c6763bba8509b7ef1757e00444be780 --- /dev/null +++ b/docs/cli/gemini-ignore.md @@ -0,0 +1,71 @@ +# Ignoring files + +This document provides an overview of the Gemini Ignore (`.geminiignore`) +feature of Gemini CLI. + +Gemini CLI includes the ability to automatically ignore files, similar to +`.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding +paths to your `.geminiignore` file will exclude them from tools that support +this feature, although they will still be visible to other services (such as +Git). + +## How it works + +When you add a path to your `.geminiignore` file, tools that respect this file +will exclude matching files and directories from their operations. For example, +when you use the `@` command to share files, any paths in your `.geminiignore` +file will be automatically excluded. + +For the most part, `.geminiignore` follows the conventions of `.gitignore` +files: + +- Blank lines and lines starting with `#` are ignored. +- Standard glob patterns are supported (such as `*`, `?`, and `[]`). +- Putting a `/` at the end will only match directories. +- Putting a `/` at the beginning anchors the path relative to the + `.geminiignore` file. +- `!` negates a pattern. + +You can update your `.geminiignore` file at any time. To apply the changes, you +must restart your Gemini CLI session. + +## How to use `.geminiignore` + +To enable `.geminiignore`: + +1. Create a file named `.geminiignore` in the root of your project directory. + +To add a file or directory to `.geminiignore`: + +1. Open your `.geminiignore` file. +2. Add the path or file you want to ignore, for example: `/archive/` or + `apikeys.txt`. + +### `.geminiignore` examples + +You can use `.geminiignore` to ignore directories and files: + +``` +# Exclude your /packages/ directory and all subdirectories +/packages/ + +# Exclude your apikeys.txt file +apikeys.txt +``` + +You can use wildcards in your `.geminiignore` file with `*`: + +``` +# Exclude all .md files +*.md +``` + +Finally, you can exclude files and directories from exclusion with `!`: + +``` +# Exclude all .md files except README.md +*.md +!README.md +``` + +To remove paths from your `.geminiignore` file, delete the relevant lines. diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md new file mode 100644 index 0000000000000000000000000000000000000000..1e3cdad3b7934a54d78d30083558c80e1b03d3db --- /dev/null +++ b/docs/cli/gemini-md.md @@ -0,0 +1,116 @@ +# Provide context with GEMINI.md files + +Context files, which use the default name `GEMINI.md`, are a powerful feature +for providing instructional context to the Gemini model. You can use these files +to give project-specific instructions, define a persona, or provide coding style +guides to make the AI's responses more accurate and tailored to your needs. + +Instead of repeating instructions in every prompt, you can define them once in a +context file. + +## Understand the context hierarchy + +The CLI uses a hierarchical system to source context. It loads various context +files from several locations, concatenates the contents of all found files, and +sends them to the model with every prompt. The CLI loads files in the following +order: + +1. **Global context file:** + + - **Location:** `~/.gemini/GEMINI.md` (in your user home directory). + - **Scope:** Provides default instructions for all your projects. + +2. **Environment and workspace context files:** + + - **Location:** The CLI searches for `GEMINI.md` files in your configured + workspace directories and their parent directories. + - **Scope:** Provides context relevant to the projects you are currently + working on. + +3. **Just-in-time (JIT) context files:** + - **Location:** When a tool accesses a file or directory, the CLI + automatically scans for `GEMINI.md` files in that directory and its + ancestors up to a trusted root. + - **Scope:** Lets the model discover highly specific instructions for + particular components only when they are needed. + +The CLI footer displays the number of loaded context files, which gives you a +quick visual cue of the active instructional context. + +### Example `GEMINI.md` file + +Here is an example of what you can include in a `GEMINI.md` file at the root of +a TypeScript project: + +```markdown +# Project: My TypeScript Library + +## General Instructions + +- When you generate new TypeScript code, follow the existing coding style. +- Ensure all new functions and classes have JSDoc comments. +- Prefer functional programming paradigms where appropriate. + +## Coding Style + +- Use 2 spaces for indentation. +- Prefix interface names with `I` (for example, `IUserService`). +- Always use strict equality (`===` and `!==`). +``` + +## Manage context with the `/memory` command + +You can interact with the loaded context files by using the `/memory` command. + +- **`/memory show`**: Displays the full, concatenated content of the current + hierarchical memory. This lets you inspect the exact instructional context + being provided to the model. +- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files + from all configured locations. + +## Modularize context with imports + +You can break down large `GEMINI.md` files into smaller, more manageable +components by importing content from other files using the `@file.md` syntax. +This feature supports both relative and absolute paths. + +**Example `GEMINI.md` with imports:** + +```markdown +# Main GEMINI.md file + +This is the main content. + +@./components/instructions.md + +More content here. + +@../shared/style-guide.md +``` + +For more details, see the [Memory Import Processor](../reference/memport.md) +documentation. + +## Customize the context file name + +While `GEMINI.md` is the default filename, you can configure this in your +`settings.json` file. To specify a different name or a list of names, use the +`context.fileName` property. + +**Example `settings.json`:** + +```json +{ + "context": { + "fileName": ["AGENTS.md", "CONTEXT.md", "GEMINI.md"] + } +} +``` + +## Next steps + +- Learn about [Ignoring files](./gemini-ignore.md) to exclude content from the + context system. +- Explore the [Memory tool](../tools/memory.md) to save persistent memories. +- See how to use [Custom commands](./custom-commands.md) to automate common + prompts. diff --git a/docs/cli/generation-settings.md b/docs/cli/generation-settings.md new file mode 100644 index 0000000000000000000000000000000000000000..c5ba2151b8a24208d793384c970321c73d2644c1 --- /dev/null +++ b/docs/cli/generation-settings.md @@ -0,0 +1,212 @@ +# Advanced Model Configuration + +This guide details the Model Configuration system within Gemini CLI. Designed +for researchers, AI quality engineers, and advanced users, this system provides +a rigorous framework for managing generative model hyperparameters and +behaviors. + + +> [!WARNING] +> This is a power-user feature. Configuration values are passed +> directly to the model provider with minimal validation. Incorrect settings +> (for example, incompatible parameter combinations) may result in runtime +> errors from the API. + +## 1. System Overview + +The Model Configuration system (`ModelConfigService`) enables deterministic +control over model generation. It decouples the requested model identifier (for +example, a CLI flag or agent request) from the underlying API configuration. +This allows for: + +- **Precise Hyperparameter Tuning**: Direct control over `temperature`, `topP`, + `thinkingBudget`, and other SDK-level parameters. +- **Environment-Specific Behavior**: Distinct configurations for different + operating contexts (for example, testing vs. production). +- **Agent-Scoped Customization**: Applying specific settings only when a + particular agent is active. + +The system operates on two core primitives: **Aliases** and **Overrides**. + +## 2. Configuration Primitives + +These settings are located under the `modelConfigs` key in your configuration +file. + +### Aliases (`customAliases`) + +Aliases are named, reusable configuration presets. Users should define their own +aliases (or override system defaults) in the `customAliases` map. + +- **Inheritance**: An alias can `extends` another alias (including system + defaults like `chat-base`), inheriting its `modelConfig`. Child aliases can + overwrite or augment inherited settings. +- **Abstract Aliases**: An alias is not required to specify a concrete `model` + if it serves purely as a base for other aliases. + +**Example Hierarchy**: + +```json +"modelConfigs": { + "customAliases": { + "base": { + "modelConfig": { + "generateContentConfig": { "temperature": 0.0 } + } + }, + "chat-base": { + "extends": "base", + "modelConfig": { + "generateContentConfig": { "temperature": 0.7 } + } + } + } +} +``` + +### Overrides (`overrides`) + +Overrides are conditional rules that inject configuration based on the runtime +context. They are evaluated dynamically for each model request. + +- **Match Criteria**: Overrides apply when the request context matches the + specified `match` properties. + - `model`: Matches the requested model name or alias. + - `overrideScope`: Matches the distinct scope of the request (typically the + agent name, for example, `codebaseInvestigator`). + +**Example Override**: + +```json +"modelConfigs": { + "overrides": [ + { + "match": { + "overrideScope": "codebaseInvestigator" + }, + "modelConfig": { + "generateContentConfig": { "temperature": 0.1 } + } + } + ] +} +``` + +## 3. Resolution Strategy + +The `ModelConfigService` resolves the final configuration through a two-step +process: + +### Step 1: Alias Resolution + +The requested model string is looked up in the merged map of system `aliases` +and user `customAliases`. + +1. If found, the system recursively resolves the `extends` chain. +2. Settings are merged from parent to child (child wins). +3. This results in a base `ResolvedModelConfig`. +4. If not found, the requested string is treated as the raw model name. + +### Step 2: Override Application + +The system evaluates the `overrides` list against the request context (`model` +and `overrideScope`). + +1. **Filtering**: All matching overrides are identified. +2. **Sorting**: Matches are prioritized by **specificity** (the number of + matched keys in the `match` object). + - Specific matches (for example, `model` + `overrideScope`) override broad + matches (for example, `model` only). + - Tie-breaking: If specificity is equal, the order of definition in the + `overrides` array is preserved (last one wins). +3. **Merging**: The configurations from the sorted overrides are merged + sequentially onto the base configuration. + +## 4. Configuration Reference + +The configuration follows the `ModelConfigServiceConfig` interface. + +### `ModelConfig` Object + +Defines the actual parameters for the model. + +| Property | Type | Description | +| :---------------------- | :------- | :------------------------------------------------------------------------ | +| `model` | `string` | The identifier of the model to be called (for example, `gemini-2.5-pro`). | +| `generateContentConfig` | `object` | The configuration object passed to the `@google/genai` SDK. | + +### `GenerateContentConfig` (Common Parameters) + +Directly maps to the SDK's `GenerateContentConfig`. Common parameters include: + +- **`temperature`**: (`number`) Controls output randomness. Lower values (0.0) + are deterministic; higher values (>0.7) are creative. +- **`topP`**: (`number`) Nucleus sampling probability. +- **`maxOutputTokens`**: (`number`) Limit on generated response length. +- **`thinkingConfig`**: (`object`) Configuration for models with reasoning + capabilities (for example, `thinkingBudget`, `includeThoughts`). + +## 5. Practical Examples + +### Defining a Deterministic Baseline + +Create an alias for tasks requiring high precision, extending the standard chat +configuration but enforcing zero temperature. + +```json +"modelConfigs": { + "customAliases": { + "precise-mode": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "temperature": 0.0, + "topP": 1.0 + } + } + } + } +} +``` + +### Agent-Specific Parameter Injection + +Enforce extended thinking budgets for a specific agent without altering the +global default, for example for the `codebaseInvestigator`. + +```json +"modelConfigs": { + "overrides": [ + { + "match": { + "overrideScope": "codebaseInvestigator" + }, + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { "thinkingBudget": 4096 } + } + } + } + ] +} +``` + +### Experimental Model Evaluation + +Route traffic for a specific alias to a preview model for A/B testing, without +changing client code. + +```json +"modelConfigs": { + "overrides": [ + { + "match": { + "model": "gemini-2.5-pro" + }, + "modelConfig": { + "model": "gemini-2.5-pro-experimental-001" + } + } + ] +} +``` diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md new file mode 100644 index 0000000000000000000000000000000000000000..5020b3fa9a402fa9c82d5bfd6643f5f75497b491 --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,107 @@ +# Git Worktrees (experimental) + +When working on multiple tasks at once, you can use Git worktrees to give each +Gemini session its own copy of the codebase. Git worktrees create separate +working directories that each have their own files and branch while sharing the +same repository history. This prevents changes in one session from colliding +with another. + +Learn more about [session management](./session-management.md). + + +> [!NOTE] +> This is an experimental feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, +> suggestions, or encounter issues: +> +> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. + +Learn more in the official Git worktree +[documentation](https://git-scm.com/docs/git-worktree). + +## How to enable Git worktrees + +Git worktrees are an experimental feature. You must enable them in your settings +using the `/settings` command or by manually editing your `settings.json` file. + +1. Use the `/settings` command. +2. Search for and set **Enable Git Worktrees** to `true`. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "worktrees": true + } +} +``` + +## How to use Git worktrees + +Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini +CLI in it. + +- **Start with a specific name:** The value you pass becomes both the directory + name (within `.gemini/worktrees/`) and the branch name. + + ```bash + gemini --worktree feature-search + ``` + +- **Start with a random name:** If you omit the name, Gemini generates a random + one automatically (for example, `worktree-a1b2c3d4`). + + ```bash + gemini --worktree + ``` + + +> [!NOTE] +> Remember to initialize your development environment in each new +> worktree according to your project's setup. Depending on your stack, this +> might include running dependency installation (`npm install`, `yarn`), setting +> up virtual environments, or following your project's standard build process. + +## How to exit a Git worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the +worktree intact so your work is not lost. This includes your uncommitted changes +(modified files, staged changes, or untracked files) and any new commits you +have made. + +Gemini prioritizes a fast and safe exit: it **does not automatically delete** +your worktree or branch. You are responsible for cleaning up your worktrees +manually once you are finished with them. + +When you exit, Gemini displays instructions on how to resume your work or how to +manually remove the worktree if you no longer need it. + +## Resuming work in a Git worktree + +To resume a session in a worktree, navigate to the worktree directory and start +Gemini CLI with the `--resume` flag and the session ID: + +```bash +cd .gemini/worktrees/feature-search +gemini --resume +``` + +## Managing Git worktrees manually + +For more control over worktree location and branch configuration, or to clean up +a preserved worktree, you can use Git directly: + +- **Clean up a preserved Git worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-search --force + git branch -D worktree-feature-search + ``` +- **Create a Git worktree manually:** + ```bash + git worktree add ../project-feature-search -b feature-search + cd ../project-feature-search && gemini + ``` + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/headless.md b/docs/cli/headless.md new file mode 100644 index 0000000000000000000000000000000000000000..c83ce70d0e326ab22316a35c47bb92ec40fe9055 --- /dev/null +++ b/docs/cli/headless.md @@ -0,0 +1,51 @@ +# Headless mode reference + +Headless mode provides a programmatic interface to Gemini CLI, returning +structured text or JSON output without an interactive terminal UI. + +## Technical reference + +Headless mode is triggered when the CLI is run in a non-TTY environment or when +providing a query with the `-p` (or `--prompt`) flag. + +### Output formats + +You can specify the output format using the `--output-format` flag. + +#### JSON output + +Returns a single JSON object containing the response and usage statistics. + +- **Schema:** + - `response`: (string) The model's final answer. + - `stats`: (object) Token usage and API latency metrics. + - `error`: (object, optional) Error details if the request failed. + +#### Streaming JSON output + +Returns a stream of newline-delimited JSON (JSONL) events. + +- **Event types:** + - `init`: Session metadata (session ID, model). + - `message`: User and assistant message chunks. + - `tool_use`: Tool call requests with arguments. + - `tool_result`: Output from executed tools. + - `error`: Non-fatal warnings and system errors. + - `result`: Final outcome with aggregated statistics and per-model token usage + breakdowns. + +## Exit codes + +The CLI returns standard exit codes to indicate the result of the headless +execution: + +- `0`: Success. +- `1`: General error or API failure. +- `42`: Input error (invalid prompt or arguments). +- `53`: Turn limit exceeded. + +## Next steps + +- Follow the [Automation tutorial](./tutorials/automation.md) for practical + scripting examples. +- See the [CLI reference](./cli-reference.md) for all available flags. diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md new file mode 100644 index 0000000000000000000000000000000000000000..a29dd98a9b998e6913fa92de1ad40a17a43b4f8e --- /dev/null +++ b/docs/cli/model-routing.md @@ -0,0 +1,59 @@ +# Model routing + +Gemini CLI includes a model routing feature that automatically switches to a +fallback model in case of a model failure. This feature is enabled by default +and provides resilience when the primary model is unavailable. + +## How it works + +Model routing is managed by the `ModelAvailabilityService`, which monitors model +health and automatically routes requests to available models based on defined +policies. + +1. **Model failure:** If the currently selected model fails (for example, due + to quota or server errors), the CLI will initiate the fallback process. + +2. **User consent:** Depending on the failure and the model's policy, the CLI + may prompt you to switch to a fallback model (by default always prompts + you). + + Some internal utility calls (such as prompt completion and classification) + use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back + to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the + configured model. + +3. **Model switch:** If approved, or if the policy allows for silent fallback, + the CLI will use an available fallback model for the current turn or the + remainder of the session. + +### Local Model Routing (Experimental) + +Gemini CLI supports using a local model for routing decisions. When configured, +Gemini CLI will use a locally-running **Gemma** model to make routing decisions +(instead of sending routing decisions to a hosted model). This feature can help +reduce costs associated with hosted model usage while offering similar routing +decision latency and quality. + +The easiest way to set this up is using the automated `gemini gemma setup` +command. + +For more details on how to configure local model routing, see +[`gemini gemma` β€” Local Model Routing Setup](../core/gemma-setup.md). + +### Model selection precedence + +The model used by Gemini CLI is determined by the following order of precedence: + +1. **`--model` command-line flag:** A model specified with the `--model` flag + when launching the CLI will always be used. +2. **`GEMINI_MODEL` environment variable:** If the `--model` flag is not used, + the CLI will use the model specified in the `GEMINI_MODEL` environment + variable. +3. **`model.name` in `settings.json`:** If neither of the above are set, the + model specified in the `model.name` property of your `settings.json` file + will be used. +4. **Local model (experimental):** If the Gemma local model router is enabled + in your `settings.json` file, the CLI will use the local Gemma model + (instead of Gemini models) to route the request to an appropriate model. +5. **Default model:** If none of the above are set, the default model will be + used. The default model is `auto` diff --git a/docs/cli/model-steering.md b/docs/cli/model-steering.md new file mode 100644 index 0000000000000000000000000000000000000000..60f07253c4f430021fe0d2d8b896d5dbbf0d3f20 --- /dev/null +++ b/docs/cli/model-steering.md @@ -0,0 +1,80 @@ +# Model steering (experimental) + +Model steering lets you provide real-time guidance and feedback to Gemini CLI +while it is actively executing a task. This lets you correct course, add missing +context, or skip unnecessary steps without having to stop and restart the agent. + + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. + +Model steering is particularly useful during complex [Plan Mode](./plan-mode.md) +workflows or long-running subagent executions where you want to ensure the agent +stays on the right track. + +## Enabling model steering + +Model steering is an experimental feature and is disabled by default. You can +enable it using the `/settings` command or by updating your `settings.json` +file. + +1. Type `/settings` in Gemini CLI. +2. Search for **Model Steering**. +3. Set the value to **true**. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "modelSteering": true + } +} +``` + +## Using model steering + +When model steering is enabled, Gemini CLI treats any text you type while the +agent is working as a steering hint. + +1. Start a task (for example, "Refactor the database service"). +2. While the agent is working (the spinner is visible), type your feedback in + the input box. +3. Press **Enter**. + +Gemini CLI acknowledges your hint with a brief message and injects it directly +into the model's context for the very next turn. The model then re-evaluates its +current plan and adjusts its actions accordingly. + +### Common use cases + +You can use steering hints to guide the model in several ways: + +- **Correcting a path:** "Actually, the utilities are in `src/common/utils`." +- **Skipping a step:** "Skip the unit tests for now and just focus on the + implementation." +- **Adding context:** "The `User` type is defined in `packages/core/types.ts`." +- **Redirecting the effort:** "Stop searching the codebase and start drafting + the plan now." +- **Handling ambiguity:** "Use the existing `Logger` class instead of creating a + new one." + +## How it works + +When you submit a steering hint, Gemini CLI performs the following actions: + +1. **Immediate acknowledgment:** It uses a small, fast model to generate a + one-sentence acknowledgment so you know your hint was received. +2. **Context injection:** It prepends an internal instruction to your hint that + tells the main agent to: + - Re-evaluate the active plan. + - Classify the update (for example, as a new task or extra context). + - Apply minimal-diff changes to affected tasks. +3. **Real-time update:** The hint is delivered to the agent at the beginning of + its next turn, ensuring the most immediate course correction possible. + +## Next steps + +- Tackle complex tasks with [Plan Mode](./plan-mode.md). +- Build custom [Agent Skills](./skills.md). diff --git a/docs/cli/model.md b/docs/cli/model.md new file mode 100644 index 0000000000000000000000000000000000000000..b85f597e080ae96e1d262b87d3c077afa5fa10d9 --- /dev/null +++ b/docs/cli/model.md @@ -0,0 +1,55 @@ +# Gemini CLI model selection (`/model` command) + +Select your Gemini CLI model. The `/model` command lets you configure the model +used by Gemini CLI, giving you more control over your results. Use **Pro** +models for complex tasks and reasoning, **Flash** models for high speed results, +or the (recommended) **Auto** setting to choose the best model for your tasks. + + +> [!NOTE] +> The `/model` command (and the `--model` flag) does not override the +> model used by sub-agents. Consequently, even when using the `/model` flag you +> may see other models used in your model usage reports. + +## How to use the `/model` command + +Use the following command in Gemini CLI: + +``` +/model +``` + +Running this command will open a dialog with your options: + +| Option | Description | Models | +| ----------------- | -------------------------------------------------------------- | -------------------------------------------- | +| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview | +| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | +| Manual | Select a specific model. | Any available model. | + +We recommend selecting one of the above **Auto** options. However, you can +select **Manual** to select a specific model from those available. + +You can also use the `--model` flag to specify a particular Gemini model on +startup. For more details, refer to the +[configuration documentation](../reference/configuration.md). + +Changes to these settings will be applied to all subsequent interactions with +Gemini CLI. + +## Best practices for model selection + +- **Default to Auto.** For most users, the _Auto_ option model provides a + balance between speed and performance, automatically selecting the correct + model based on the complexity of the task. Example: Developing a web + application could include a mix of complex tasks (building architecture and + scaffolding the project) and simple tasks (generating CSS). + +- **Switch to Pro if you aren't getting the results you want.** If you think you + need your model to be a little "smarter," you can manually select Pro. Pro + will provide you with the highest levels of reasoning and creativity. Example: + A complex or multi-stage debugging task. + +- **Switch to Flash or Flash-Lite if you need faster results.** If you need a + simple response quickly, Flash or Flash-Lite is the best option. Example: + Converting a JSON object to a YAML string. diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md new file mode 100644 index 0000000000000000000000000000000000000000..abe6743c56b8b2518c7c1e190f84c42c691513c2 --- /dev/null +++ b/docs/cli/notifications.md @@ -0,0 +1,59 @@ +# Notifications (experimental) + +Gemini CLI can send system notifications to alert you when a session completes +or when it needs your attention, such as when it's waiting for you to approve a +tool call. + + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. + +Notifications are particularly useful when running long-running tasks or using +[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini +CLI works in the background. + +## Requirements + +### Terminal support + +The CLI uses the OSC 9 terminal escape sequence to trigger system notifications. +This is supported by several modern terminal emulators including iTerm2, +WezTerm, Ghostty, and Kitty. If your terminal does not support OSC 9 +notifications, Gemini CLI falls back to a terminal bell (BEL) to get your +attention. Most terminals respond to BEL with a taskbar flash or system alert +sound. + +## Enable notifications + +Notifications are disabled by default. You can enable them using the `/settings` +command or by updating your `settings.json` file. + +1. Open the settings dialog by typing `/settings` in an interactive session. +2. Navigate to the **General** category. +3. Toggle the **Enable Notifications** setting to **On**. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "general": { + "enableNotifications": true + } +} +``` + +## Types of notifications + +Gemini CLI sends notifications for the following events: + +- **Action required:** Triggered when the model is waiting for user input or + tool approval. This helps you know when the CLI has paused and needs you to + intervene. +- **Session complete:** Triggered when a session finishes successfully. This is + useful for tracking the completion of automated tasks. + +## Next steps + +- Start planning with [Plan Mode](./plan-mode.md). +- Configure your experience with other [settings](./settings.md). diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md new file mode 100644 index 0000000000000000000000000000000000000000..4e84caf06b386fa4426c73c60c3a77d43e1f5318 --- /dev/null +++ b/docs/cli/plan-mode.md @@ -0,0 +1,509 @@ +# Plan Mode + +Plan Mode is a read-only environment for architecting robust solutions before +implementation. With Plan Mode, you can: + +- **Research:** Explore the project in a read-only state to prevent accidental + changes. +- **Design:** Understand problems, evaluate trade-offs, and choose a solution. +- **Plan:** Align on an execution strategy before any code is modified. + +Plan Mode is enabled by default. You can manage this setting using the +`/settings` command. + +## How to enter Plan Mode + +Plan Mode integrates seamlessly into your workflow, letting you switch between +planning and execution as needed. + +You can either configure Gemini CLI to start in Plan Mode by default or enter +Plan Mode manually during a session. + +### Launch in Plan Mode + +To start Gemini CLI directly in Plan Mode by default: + +1. Use the `/settings` command. +2. Set **Default Approval Mode** to `Plan`. + +To launch Gemini CLI in Plan Mode once: + +1. Use `gemini --approval-mode=plan` when launching Gemini CLI. + +### Enter Plan Mode manually + +To start Plan Mode while using Gemini CLI: + +- **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes + (`Default` -> `Auto-Edit` -> `Plan`). Plan Mode is automatically removed from + the rotation when Gemini CLI is actively processing or showing confirmation + dialogs. + +- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional; + for example, `/plan implement authentication` will switch to Plan Mode and + immediately submit the prompt to the model. + +- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI + calls the + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool + to switch modes. This tool is not available when Gemini CLI is in + [YOLO mode](../reference/configuration.md#command-line-arguments). + +## How to use Plan Mode + +Plan Mode lets you collaborate with Gemini CLI to design a solution before +Gemini CLI takes action. + +1. **Provide a goal:** Start by describing what you want to achieve. Gemini CLI + will then enter Plan Mode (if it's not already) to research the task. +2. **Discuss and agree on strategy:** As Gemini CLI analyzes your codebase, it + will discuss its findings and proposed strategy with you to ensure + alignment. It may ask you questions or present different implementation + options using [`ask_user`](../tools/ask-user.md). **Gemini CLI will stop and + wait for your confirmation** before drafting the formal plan. You should + reach an informal agreement on the approach before proceeding. +3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates + a detailed implementation plan as a Markdown file in your plans directory. + + - **View:** You can open and read this file to understand the proposed + changes. + - **Edit:** Press `Ctrl+X` to open the plan directly in your configured + external editor. + +4. **Approve or iterate:** Gemini CLI will present the finalized plan for your + formal approval. + - **Approve:** If you're satisfied with the plan, approve it to start the + implementation immediately: **Yes, automatically accept edits** or **Yes, + manually accept edits**. + - **Iterate:** If the plan needs adjustments, provide feedback in the input + box or [edit the plan file directly](#collaborative-plan-editing). Gemini + CLI will refine the strategy and update the plan. + - **Cancel:** You can cancel your plan with `Esc`. + +For more complex or specialized planning tasks, you can +[customize the planning workflow with skills](#custom-planning-with-skills). + +### Collaborative plan editing + +You can collaborate with Gemini CLI by making direct changes or leaving comments +in the implementation plan. This is often faster and more precise than +describing complex changes in natural language. + +1. **Open the plan:** Press `Ctrl+X` when Gemini CLI presents a plan for + review. +2. **Edit or comment:** The plan opens in your configured external editor (for + example, VS Code or Vim). You can: + - **Modify steps:** Directly reorder, delete, or rewrite implementation + steps. + - **Leave comments:** Add inline questions or feedback (for example, "Wait, + shouldn't we use the existing `Logger` class here?"). +3. **Save and close:** Save your changes and close the editor. +4. **Review and refine:** Gemini CLI automatically detects the changes, reviews + your comments, and adjusts the implementation strategy. It then presents the + refined plan for your final approval. + +## How to exit Plan Mode + +You can exit Plan Mode at any time, whether you have finalized a plan or want to +switch back to another mode. + +- **Approve a plan:** When Gemini CLI presents a finalized plan, approving it + automatically exits Plan Mode and starts the implementation. +- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode. +- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning." + +## Tool Restrictions + +Plan Mode enforces strict safety policies to prevent accidental changes. + +These are the only allowed tools: + +- **FileSystem (Read):** + [`read_file`](../tools/file-system.md#2-read_file-readfile), + [`list_directory`](../tools/file-system.md#1-list_directory-readfolder), + [`glob`](../tools/file-system.md#4-glob-findfiles) +- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext), + [`google_web_search`](../tools/web-search.md), + [`web_fetch`](../tools/web-fetch.md) (requires explicit confirmation), + [`get_internal_docs`](../tools/internal-docs.md) +- **Research Subagents:** + [`codebase_investigator`](../core/subagents.md#codebase-investigator), + [`cli_help`](../core/subagents.md#cli-help-agent) +- **Interaction:** [`ask_user`](../tools/ask-user.md) +- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for + example, `github_read_issue`, `postgres_read_schema`) and core + [MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`, + `read_mcp_resource`) are allowed. +- **Planning (Write):** + [`write_file`](../tools/file-system.md#3-write_file-writefile) and + [`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md` + files in the `~/.gemini/tmp///plans/` directory or your + [custom plans directory](#custom-plan-directory-and-policies). +- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized + instructions and resources in a read-only manner) + +## Customization and best practices + +Plan Mode is secure by default, but you can adapt it to fit your specific +workflows. You can customize how Gemini CLI plans by using skills, adjusting +safety policies, changing where plans are stored, or adding hooks. + +### Custom planning with skills + +You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI +approaches planning for specific types of tasks. When a skill is activated +during Plan Mode, its specialized instructions and procedural workflows will +guide the research, design, and planning phases. + +For example: + +- A **"Database Migration"** skill could ensure the plan includes data safety + checks and rollback strategies. +- A **"Security Audit"** skill could prompt Gemini CLI to look for specific + vulnerabilities during codebase exploration. +- A **"Frontend Design"** skill could guide Gemini CLI to use specific UI + components and accessibility standards in its proposal. + +To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the +`` skill to plan..." or Gemini CLI may autonomously activate it +based on the task description. + +### Custom policies + +Plan Mode's default tool restrictions are managed by the +[policy engine](../reference/policy-engine.md) and defined in the built-in +[`plan.toml`] file. The built-in policy (Tier 1) enforces the read-only state, +but you can customize these rules by creating your own policies in your +`~/.gemini/policies/` directory (Tier 2). + +#### Global vs. mode-specific rules + +As described in the +[policy engine documentation](../reference/policy-engine.md#approval-modes), any +rule that does not explicitly specify `modes` is considered "always active" and +will apply to Plan Mode as well. + +To maintain the integrity of Plan Mode as a safe research environment, +persistent tool approvals are context-aware. Approvals granted in modes like +Default or Auto-Edit do not apply to Plan Mode, ensuring that tools trusted for +implementation don't automatically execute while you're researching. However, +approvals granted while in Plan Mode are treated as intentional choices for +global trust and apply to all modes. + +If you want to manually restrict a rule to other modes but _not_ to Plan Mode, +you must explicitly specify the target modes. For example, to allow `npm test` +in default and Auto-Edit modes but not in Plan Mode: + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = "npm test" +decision = "allow" +priority = 100 +# By omitting "plan", this rule will not be active in Plan Mode. +modes = ["default", "autoEdit"] +``` + +#### Example: Automatically approve read-only MCP tools + +By default, read-only MCP tools require user confirmation in Plan Mode. You can +use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for +your specific environment. + +`~/.gemini/policies/mcp-read-only.toml` + +```toml +[[rule]] +toolName = "*" +mcpName = "*" +toolAnnotations = { readOnlyHint = true } +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +For more information on how the policy engine works, see the +[policy engine](../reference/policy-engine.md) docs. + +#### Example: Allow git commands in Plan Mode + +This rule lets you check the repository status and see changes while in Plan +Mode. + +`~/.gemini/policies/git-research.toml` + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +#### Example: Enable custom subagents in Plan Mode + +Built-in research [subagents](../core/subagents.md) like +[`codebase_investigator`](../core/subagents.md#codebase-investigator) and +[`cli_help`](../core/subagents.md#cli-help-agent) are enabled by default in Plan +Mode. You can enable additional +[custom subagents](../core/subagents.md#creating-custom-subagents) by adding a +rule to your policy. + +`~/.gemini/policies/research-subagents.toml` + +```toml +[[rule]] +toolName = "my_custom_subagent" +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +Tell Gemini CLI it can use these tools in your prompt, for example: _"You can +check ongoing changes in git."_ + +### Custom plan directory and policies + +By default, planning artifacts are stored in a managed temporary directory +outside your project: `~/.gemini/tmp///plans/`. + +You can configure a custom directory for plans in your `settings.json`. For +example, to store plans in a `.gemini/plans` directory within your project: + +```json +{ + "general": { + "plan": { + "directory": ".gemini/plans" + } + } +} +``` + +To maintain the safety of Plan Mode, user-configured paths for the plans +directory are restricted to the project root. This ensures that custom planning +locations defined within a project's workspace cannot be used to escape and +overwrite sensitive files elsewhere. Any user-configured directory must reside +within the project boundary. + +Using a custom directory requires updating your +[policy engine](../reference/policy-engine.md) configurations to allow +`write_file` and `replace` in that specific location. For example, to allow +writing to the `.gemini/plans` directory within your project, create a policy +file at `~/.gemini/policies/plan-custom-directory.toml`: + +```toml +[[rule]] +toolName = ["write_file", "replace"] +decision = "allow" +priority = 100 +modes = ["plan"] +# Adjust the pattern to match your custom directory. +# This example matches any .md file in a .gemini/plans directory within the project. +argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"" +``` + +### Using hooks with Plan Mode + +You can use the [hook system](../hooks/writing-hooks.md) to automate parts of +the planning workflow or enforce additional checks when Gemini CLI transitions +into or out of Plan Mode. + +Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the +`enter_plan_mode` and `exit_plan_mode` tool calls. + +> [!WARNING] When hooks are triggered by **tool executions**, they do **not** +> run when you manually toggle Plan Mode using the `/plan` command or the +> `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes, +> ensure the transition is initiated by the agent (for example, by asking "start +> a plan for..."). + +#### Example: Archive approved plans to GCS (`AfterTool`) + +If your organizational policy requires a record of all execution plans, you can +use an `AfterTool` hook to securely copy the plan artifact to Google Cloud +Storage whenever Gemini CLI exits Plan Mode to start the implementation. + +**`.gemini/hooks/archive-plan.sh`:** + +```bash +#!/usr/bin/env bash +# Extract the plan filename from the tool input JSON +plan_filename=$(jq -r '.tool_input.plan_filename // empty') + +# Construct the absolute path using the GEMINI_PLANS_DIR environment variable +plan_path="$GEMINI_PLANS_DIR/$plan_filename" + +if [ -f "$plan_path" ]; then + # Generate a unique filename using a timestamp + filename="$(date +%s)_$(basename "$plan_path")" + + # Upload the plan to GCS in the background so it doesn't block the CLI + gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 & +fi + +# AfterTool hooks should generally allow the flow to continue +echo '{"decision": "allow"}' +``` + +To register this `AfterTool` hook, add it to your `settings.json`: + +```json +{ + "hooks": { + "AfterTool": [ + { + "matcher": "exit_plan_mode", + "hooks": [ + { + "name": "archive-plan", + "type": "command", + "command": "~/.gemini/hooks/archive-plan.sh" + } + ] + } + ] + } +} +``` + +## Commands + +- **`/plan copy`**: Copy the currently approved plan to your clipboard. + +## Planning workflows + +Plan Mode provides building blocks for structured research and design. These are +implemented as [extensions](../extensions/index.md) using core planning tools +like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), +[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode), and +[`ask_user`](../tools/ask-user.md). + +### Built-in planning workflow + +The built-in planner uses an adaptive workflow to analyze your project, consult +you on trade-offs via [`ask_user`](../tools/ask-user.md), and draft a plan for +your approval. + +### Custom planning workflows + +You can install or create specialized planners to suit your workflow. + +#### Conductor + +[Conductor] is designed for spec-driven development. It organizes work into +"tracks" and stores persistent artifacts in your project's `conductor/` +directory: + +- **Automate transitions:** Switches to read-only mode via + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode). +- **Streamline decisions:** Uses [`ask_user`](../tools/ask-user.md) for + architectural choices. +- **Maintain project context:** Stores artifacts in the project directory using + [custom plan directory and policies](#custom-plan-directory-and-policies). +- **Handoff execution:** Transitions to implementation via + [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode). + +#### Build your own + +Since Plan Mode is built on modular building blocks, you can develop your own +custom planning workflow as an [extensions](../extensions/index.md). By +leveraging core tools and [custom policies](#custom-policies), you can define +how Gemini CLI researches and stores plans for your specific domain. + +To build a custom planning workflow, you can use: + +- **Tool usage:** Use core tools like + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), + [`ask_user`](../tools/ask-user.md), and + [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode) to + manage the research and design process. +- **Customization:** Set your own storage locations and policy rules using + [custom plan directories](#custom-plan-directory-and-policies) and + [custom policies](#custom-policies). + + +> [!TIP] +> Use [Conductor] as a reference when building your own custom +> planning workflow. + +By using Plan Mode as its execution environment, your custom methodology can +enforce read-only safety during the design phase while benefiting from +high-reasoning model routing. + +## Automatic Model Routing + +When using an [auto model](../reference/configuration.md#model), Gemini CLI +automatically optimizes [model routing](../cli/telemetry.md#model-routing) based +on the current phase of your task: + +1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a + high-reasoning **Pro** model to ensure robust architectural decisions and + high-quality plans. +2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode, + the CLI detects the existence of the approved plan and automatically + switches to a high-speed **Flash** model. This provides a faster, more + responsive experience during the implementation of the plan. + +If the high-reasoning model is unavailable or you don't have access to it, +Gemini CLI automatically and silently falls back to a faster model to ensure +your workflow isn't interrupted. + +This behavior is enabled by default to provide the best balance of quality and +performance. You can disable this automatic switching in your settings: + +```json +{ + "general": { + "plan": { + "modelRouting": false + } + } +} +``` + +## Cleanup + +By default, Gemini CLI automatically cleans up old session data, including all +associated plan files and task trackers. + +- **Default behavior:** Sessions (and their plans) are retained for **30 days**. +- **Configuration:** You can customize this behavior via the `/settings` command + (search for **Enable Session Cleanup** or **Keep chat history**) or in your + `settings.json` file. See + [session retention](../cli/session-management.md#session-retention) for more + details. + +Manual deletion also removes all associated artifacts: + +- **Command Line:** Use `gemini --delete-session `. +- **Session Browser:** Press `/resume`, navigate to a session, and press `x`. + +If you use a [custom plans directory](#custom-plan-directory-and-policies), +those files are not automatically deleted and must be managed manually. + +## Non-interactive execution + +When running Gemini CLI in non-interactive environments (such as headless +scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: + +- **Automatic transitions:** The policy engine automatically approves the + `enter_plan_mode` and `exit_plan_mode` tools without prompting for user + confirmation. +- **Automated implementation:** When exiting Plan Mode to execute the plan, + Gemini CLI automatically switches to + [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the + standard Default mode. This allows the CLI to execute the implementation steps + automatically without hanging on interactive tool approvals. + +**Example:** + +```bash +gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" +``` + +[`plan.toml`]: + https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml +[Conductor]: https://github.com/gemini-cli-extensions/conductor +[open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/rewind.md b/docs/cli/rewind.md new file mode 100644 index 0000000000000000000000000000000000000000..98ed72a825367a38072a0b0f43598cafffcc5a1a --- /dev/null +++ b/docs/cli/rewind.md @@ -0,0 +1,51 @@ +# Rewind + +The `/rewind` command lets you go back to a previous state in your conversation +and, optionally, revert any file changes made by the AI during those +interactions. This is a powerful tool for undoing mistakes, exploring different +approaches, or simply cleaning up your session history. + +## Usage + +To use the rewind feature, simply type `/rewind` into the input prompt and press +**Enter**. + +Alternatively, you can use the keyboard shortcut: **Press `Esc` twice**. + +## Interface + +When you trigger a rewind, an interactive list of your previous interactions +appears. + +1. **Select interaction:** Use the **Up/Down arrow keys** to navigate through + the list. The most recent interactions are at the bottom. +2. **Preview:** As you select an interaction, you'll see a preview of the user + prompt and, if applicable, the number of files changed during that step. +3. **Confirm selection:** Press **Enter** on the interaction you want to rewind + back to. +4. **Action selection:** After selecting an interaction, you'll be presented + with a confirmation dialog with up to three options: + - **Rewind conversation and revert code changes:** Reverts both the chat + history and the file modifications to the state before the selected + interaction. + - **Rewind conversation:** Only reverts the chat history. File changes are + kept. + - **Revert code changes:** Only reverts the file modifications. The chat + history is kept. + - **Do nothing (esc):** Cancels the rewind operation. + +If no code changes were made since the selected point, the options related to +reverting code changes will be hidden. + +## Key considerations + +- **Destructive action:** Rewinding is a destructive action for your current + session history and potentially your files. Use it with care. +- **Agent awareness:** When you rewind the conversation, the AI model loses all + memory of the interactions that were removed. If you only revert code changes, + you may need to inform the model that the files have changed. +- **Manual edits:** Rewinding only affects file changes made by the AI's edit + tools. It does **not** undo manual edits you've made or changes triggered by + the shell tool (`!`). +- **Compression:** Rewind works across chat compression points by reconstructing + the history from stored session data. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md new file mode 100644 index 0000000000000000000000000000000000000000..5beb243aa15192b9b29ddb14ace30eb1d9fd2454 --- /dev/null +++ b/docs/cli/sandbox.md @@ -0,0 +1,473 @@ +# Sandboxing in Gemini CLI + +This document provides a guide to sandboxing in Gemini CLI, including +prerequisites, quickstart, and configuration. + +## Prerequisites + +Before using sandboxing, you need to install and set up Gemini CLI: + +```bash +npm install -g @google/gemini-cli +``` + +To verify the installation: + +```bash +gemini --version +``` + +## Overview of sandboxing + +Sandboxing isolates potentially dangerous operations (such as shell commands or +file modifications) from your host system, providing a security barrier between +AI operations and your environment. + +The benefits of sandboxing include: + +- **Security**: Prevent accidental system damage or data loss. +- **Isolation**: Limit file system access to project directory. +- **Consistency**: Ensure reproducible environments across different systems. +- **Safety**: Reduce risk when working with untrusted code or experimental + commands. + +## Quickstart + +You can enable sandboxing using a command flag, environment variable, or +configuration file. + +### Using the command flag + +```bash +gemini -s -p "analyze the code structure" +``` + +### Using an environment variable + +**macOS/Linux** + +```bash +export GEMINI_SANDBOX=true +gemini -p "run the test suite" +``` + +**Windows (PowerShell)** + +```powershell +$env:GEMINI_SANDBOX="true" +gemini -p "run the test suite" +``` + +### Configuring via settings.json + +```json +{ + "tools": { + "sandbox": "docker" + } +} +``` + +## Configuration + +Enable sandboxing using one of the following methods (in order of precedence): + +1. **Command flag**: `-s` or `--sandbox` +2. **Environment variable**: + `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc` +3. **Settings file**: `"sandbox": true` in the `tools` object of your + `settings.json` file (for example, `{"tools": {"sandbox": true}}`). + +## Sandboxing methods + +Your ideal method of sandboxing may differ depending on your platform and your +preferred container solution. + +### 1. macOS Seatbelt (macOS only) + +Lightweight, built-in sandboxing using `sandbox-exec`. + +**Default profile**: `permissive-open` - denies operations by default; confines +writes to the project directory while allowing broad file reads and network +access. + +Built-in profiles (set via `SEATBELT_PROFILE` env var): + +- `permissive-open` (default): Write restrictions, network allowed +- `permissive-proxied`: Write restrictions, network via proxy +- `restrictive-open`: Strict restrictions, network allowed +- `restrictive-proxied`: Strict restrictions, network via proxy +- `strict-open`: Read and write restrictions, network allowed +- `strict-proxied`: Read and write restrictions, network via proxy + +### 2. Container-based (Docker/Podman) + +Cross-platform sandboxing with complete process isolation using container +technology. By default, it uses the `ghcr.io/google/gemini-cli:latest` image. + +**Prerequisites:** + +- Docker or Podman must be installed and running on your system. + +**How it works (Workspace directory):** + +Inside the sandbox container, your current working directory is mounted at the +**exact same absolute path** as it is on your host machine. For example, if you +run the CLI from `/Users/you/project` on your host machine, the sandbox will +mount your local project folder and operate within `/Users/you/project` inside +the container. This allows the AI to seamlessly read and modify your project +files while remaining isolated from the rest of your system. + +**Quick setup:** + +To enable Docker sandboxing, run Gemini CLI with the sandbox flag and specify +Docker as the provider: + +```bash +# Using the environment variable (Recommended) +export GEMINI_SANDBOX=docker +gemini -p "build the project" + +# Or configure it permanently in your settings.json +# {"tools": {"sandbox": "docker"}} +``` + +**Customizing the Sandbox Image:** + +If your project requires specific dependencies, you can specify a custom image +name or have Gemini CLI build one for you automatically. You can use any Docker +or Podman image as your sandbox, provided it has standard shell utilities (like +`bash`) available. + +**Option A: Using an existing custom image (e.g., Artifact Registry)** + +To configure a custom image that is hosted on a registry (or built locally), +update your `settings.json` to use an object for the sandbox configuration, or +set the `GEMINI_SANDBOX_IMAGE` environment variable. + +_Example: Configuring via `settings.json`_ + +```json +{ + "tools": { + "sandbox": { + "command": "docker", + "image": "us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest" + } + } +} +``` + +_Example: Configuring via environment variable_ + +```bash +export GEMINI_SANDBOX_IMAGE="us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest" +``` + +**Option B: Building a local custom image automatically** + +If you prefer to define your environment as code, you can provide a Dockerfile +and Gemini CLI will build the image automatically. + +1. Create a `.gemini/sandbox.Dockerfile` in your project root. +2. Ensure you have the `gh` CLI installed and authenticated (if you are using + the default `ghcr.io/google/gemini-cli` image as a base). +3. Run your command with the `BUILD_SANDBOX` environment variable set: + +```bash +BUILD_SANDBOX=1 GEMINI_SANDBOX=docker gemini -p "run my custom build" +``` + +### 3. Windows Native Sandbox (Windows only) + +... **Troubleshooting and Side Effects:** + +The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory +Level" on files and directories it needs to write to. + +- **Persistence**: These integrity level changes are persistent on the + filesystem. Even after the sandbox session ends, files created or modified by + the sandbox will retain their "Low" integrity level. +- **Manual Reset**: If you need to reset the integrity level of a file or + directory, you can use: + ```powershell + icacls "C:\path\to\dir" /setintegritylevel Medium + ``` +- **System Folders**: The sandbox manager automatically skips setting integrity + levels on system folders (like `C:\Windows`) for safety. + +### 4. gVisor / runsc (Linux only) + +Strongest isolation available: runs containers inside a user-space kernel via +[gVisor](https://github.com/google/gvisor). gVisor intercepts all container +system calls and handles them in a sandboxed kernel written in Go, providing a +strong security barrier between AI operations and the host OS. + +**Prerequisites:** + +- Linux (gVisor supports Linux only) +- Docker installed and running +- gVisor/runsc runtime configured + +When you set `sandbox: "runsc"`, Gemini CLI runs +`docker run --runtime=runsc ...` to execute containers with gVisor isolation. +runsc is not auto-detected; you must specify it explicitly (e.g. +`GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`). + +To set up runsc: + +1. Install the runsc binary. +2. Configure the Docker daemon to use the runsc runtime. +3. Verify the installation. + +### 5. LXC/LXD (Linux only, experimental) + +Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC +containers run a complete Linux system with `systemd`, `snapd`, and other system +services. This is ideal for tools that don't work in standard Docker containers, +such as Snapcraft and Rockcraft. + +**Prerequisites**: + +- Linux only. +- LXC/LXD must be installed (`snap install lxd` or `apt install lxd`). +- A container must be created and running before starting Gemini CLI. Gemini + does **not** create the container automatically. + +**Quick setup**: + +```bash +# Initialize LXD (first time only) +lxd init --auto + +# Create and start an Ubuntu container +lxc launch ubuntu:24.04 gemini-sandbox + +# Enable LXC sandboxing +export GEMINI_SANDBOX=lxc +gemini -p "build the project" +``` + +**Custom container name**: + +```bash +export GEMINI_SANDBOX=lxc +export GEMINI_SANDBOX_IMAGE=my-snapcraft-container +gemini -p "build the snap" +``` + +**Limitations**: + +- Linux only (LXC is not available on macOS or Windows). +- The container must already exist and be running. +- The workspace directory is bind-mounted into the container at the same + absolute path β€” the path must be writable inside the container. +- Used with tools like Snapcraft or Rockcraft that require a full system. + +## Tool sandboxing + +Tool-level sandboxing provides granular isolation for individual tool executions +(like `shell_exec` and `write_file`) instead of sandboxing the entire Gemini CLI +process. + +This approach offers better integration with your local environment for non-tool +tasks (like UI rendering and configuration loading) while still providing +security for tool-driven operations. + +### How to turn off tool sandboxing + +If you experience issues with tool sandboxing or prefer full-process isolation, +you can disable it by setting `security.toolSandboxing` to `false` in your +`settings.json` file. + +```json +{ + "security": { + "toolSandboxing": false + } +} +``` + + +> [!NOTE] +> Changing the `security.toolSandboxing` setting requires a restart of Gemini +> CLI to take effect. + +## Sandbox expansion + +Sandbox expansion is a dynamic permission system that lets Gemini CLI request +additional permissions for a command when needed. + +When a sandboxed command fails due to permission restrictions (like restricted +file paths or network access), or when a command is proactively identified as +requiring extra permissions (like `npm install`), Gemini CLI will present you +with a "Sandbox Expansion Request." + +### How sandbox expansion works + +1. **Detection**: Gemini CLI detects a sandbox denial or proactively identifies + a command that requires extra permissions. +2. **Request**: A modal dialog is shown, explaining which additional + permissions (e.g., specific directories or network access) are required. +3. **Approval**: If you approve the expansion, the command is executed with the + extended permissions for that specific run. + +This mechanism ensures you don't have to manually re-run commands with more +permissive sandbox settings, while still maintaining control over what the AI +can access. + +### Including files outside the workspace + +By default, the sandbox only has access to the current project workspace. If you +need the sandbox to have permission to operate on certain files or directories +from the local file system outside of the project workspace, you can mount them +using the `SANDBOX_MOUNTS` environment variable. + +Provide a comma-separated list of mount definitions in the format +`from:to:opts`. If `to` is omitted, it defaults to the same path as `from`. If +`opts` is omitted, it defaults to `ro` (read-only). Note that the `from` path +must be an absolute path. + +**Example**: + +```bash +export SANDBOX_MOUNTS="/path/on/host:/path/in/container:rw,/another/path:ro" +``` + +## Running inside a Docker container + +If you are running Gemini CLI itself from within an official or custom Docker +container and want to enable sandboxing, you must share the host's Docker socket +and ensure your workspace paths align. + +1. **Mount the Docker socket**: Map `/var/run/docker.sock` so the CLI can spawn + sibling sandbox containers via the host's Docker daemon. +2. **Align workspace paths**: The path to your workspace inside the container + must exactly match the absolute path on the host. Because the sandbox + container is spawned by the host's Docker daemon, it resolves volume mounts + against the host file system. + +**Example**: + +```bash +docker run -it \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /absolute/path/on/host/project:/absolute/path/on/host/project \ + -w /absolute/path/on/host/project \ + -e GEMINI_SANDBOX=docker \ + ghcr.io/google/gemini-cli:latest +``` + +## Advanced settings + +### Custom sandbox flags + +For container-based sandboxing, you can inject custom flags into the `docker` or +`podman` command using the `SANDBOX_FLAGS` environment variable. This is useful +for advanced configurations, such as disabling security features for specific +use cases. + +**Example (Podman)**: + +To disable SELinux labeling for volume mounts, you can set the following: + +**macOS/Linux** + +```bash +export SANDBOX_FLAGS="--security-opt label=disable" +``` + +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_FLAGS="--security-opt label=disable" +``` + +Multiple flags can be provided as a space-separated string: + +**macOS/Linux** + +```bash +export SANDBOX_FLAGS="--flag1 --flag2=value" +``` + +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_FLAGS="--flag1 --flag2=value" +``` + +### Linux UID/GID handling + +The sandbox automatically handles user permissions on Linux. Override these +permissions with: + +**macOS/Linux** + +```bash +export SANDBOX_SET_UID_GID=true # Force host UID/GID +export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping +``` + +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_SET_UID_GID="true" # Force host UID/GID +$env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping +``` + +## Troubleshooting + +### Common issues + +**"Operation not permitted"** + +- Operation requires access outside sandbox. +- Try more permissive profile or add mount points. + +**Missing commands** + +- Add to a custom Dockerfile. Automatic `BUILD_SANDBOX` builds are only + available when running Gemini CLI from source; npm installs need a prebuilt + image instead. +- Install via `sandbox.bashrc`. + +**Network issues** + +- Check sandbox profile allows network. +- Verify proxy configuration. + +### Debug mode + +```bash +DEBUG=1 gemini -s -p "debug command" +``` + + +> [!NOTE] +> If you have `DEBUG=true` in a project's `.env` file, it won't affect +> gemini-cli due to automatic exclusion. Use `.gemini/.env` files for +> gemini-cli specific debug settings. + +### Inspect sandbox + +```bash +# Check environment +gemini -s -p "run shell command: env | grep SANDBOX" + +# List mounts +gemini -s -p "run shell command: mount | grep workspace" +``` + +## Security notes + +- Sandboxing reduces but doesn't eliminate all risks. +- Use the most restrictive profile that allows your work. +- Container overhead is minimal after first build. +- GUI applications may not work in sandboxes. + +## Related documentation + +- [Configuration](../reference/configuration.md): Full configuration options. +- [Commands](../reference/commands.md): Available commands. +- [Troubleshooting](../resources/troubleshooting.md): General troubleshooting. diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md new file mode 100644 index 0000000000000000000000000000000000000000..c18dd1523df07a9792ef76bab7960c5914bfcd24 --- /dev/null +++ b/docs/cli/session-management.md @@ -0,0 +1,215 @@ +# Session management + +Session management saves your conversation history so you can resume your work +where you left off. Use these features to review past interactions, manage +history across different projects, and configure how long data is retained. + +## Automatic saving + +Your session history is recorded automatically as you interact with the model. +This background process ensures your work is preserved even if you interrupt a +session. + +- **What is saved:** The complete conversation history, including: + - Your prompts and the model's responses. + - All tool executions (inputs and outputs). + - Token usage statistics (input, output, cached, etc.). + - Assistant thoughts and reasoning summaries (when available). +- **Location:** Sessions are stored in `~/.gemini/tmp//chats/`, + where `` is a unique identifier based on your project's root + directory. +- **Scope:** Sessions are project-specific. Switching directories to a different + project switches to that project's session history. + +## Resuming sessions + +You can resume a previous session to continue the conversation with all prior +context restored. Resuming is supported both through command-line flags and an +interactive browser. + +### From the command line + +When starting Gemini CLI, use the `--resume` (or `-r`) flag to load existing +sessions. + +- **Resume latest:** + + ```bash + gemini --resume + ``` + + This immediately loads the most recent session. + +- **Resume by index:** List available sessions first (see + [Listing sessions](#listing-sessions)), then use the index number: + + ```bash + gemini --resume 1 + ``` + +- **Resume by ID:** You can also provide the full session UUID: + ```bash + gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890 + ``` + +### From the interactive interface + +While the CLI is running, use the `/resume` slash command to open the **Session +Browser**: + +```text +/resume +``` + +When typing `/resume` (or `/chat`) in slash completion, commands are grouped +under titled separators: + +- `-- auto --` (session browser) + - `list` is selectable and opens the session browser +- `-- checkpoints --` (manual tagged checkpoint commands) + +Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu. + +The Session Browser provides an interactive interface where you can perform the +following actions: + +- **Browse:** Scroll through a list of your past sessions. +- **Preview:** See details like the session date, message count, and the first + user prompt. +- **Search:** Press `/` to enter search mode, then type to filter sessions by ID + or content. +- **Select:** Press **Enter** to resume the selected session. +- **Esc:** Press **Esc** to exit the Session Browser. + +### Manual chat checkpoints + +For named branch points inside a session, use chat checkpoints: + +```text +/resume save decision-point +/resume list +/resume resume decision-point +``` + +Compatibility aliases: + +- `/chat ...` works for the same commands. +- `/resume checkpoints ...` also remains supported during migration. + +## Parallel sessions with Git worktrees + +When working on multiple tasks at once, you can use +[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of +the codebase. This prevents changes in one session from colliding with another. + +## Managing sessions + +You can list and delete sessions to keep your history organized and manage disk +space. + +### Listing sessions + +To see a list of all available sessions for the current project from the command +line, use the `--list-sessions` flag: + +```bash +gemini --list-sessions +``` + +Output example: + +```text +Available sessions for this project (3): + + 1. Fix bug in auth (2 days ago) [a1b2c3d4] + 2. Refactor database schema (5 hours ago) [e5f67890] + 3. Update documentation (Just now) [abcd1234] +``` + +### Deleting sessions + +You can remove old or unwanted sessions to free up space or declutter your +history. + +**From the command line:** Use the `--delete-session` flag with an index or ID: + +```bash +gemini --delete-session 2 +``` + +**From the Session Browser:** + +1. Open the browser with `/resume`. +2. Navigate to the session you want to remove. +3. Press **x**. + +## Configuration + +You can configure how Gemini CLI manages your session history in your +`settings.json` file. These settings let you control retention policies and +session lengths. + +### Session retention + +By default, Gemini CLI automatically cleans up old session data to prevent your +history from growing indefinitely. When a session is deleted, Gemini CLI also +removes all associated data, including implementation plans, task trackers, tool +outputs, and activity logs. + +The default policy is to **retain sessions for 30 days**. + +#### Configuration + +You can customize these policies using the `/settings` command or by manually +editing your `settings.json` file: + +```json +{ + "general": { + "sessionRetention": { + "enabled": true, + "maxAge": "30d", + "maxCount": 50 + } + } +} +``` + +- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to + `true`. +- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", + "4w"). Sessions older than this are deleted. Defaults to `"30d"`. +- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest + sessions exceeding this count are deleted. Defaults to undefined (unlimited). +- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults + to `"1d"`. Sessions newer than this period are never deleted by automatic + cleanup. + +### Session limits + +You can limit the length of individual sessions to prevent context windows from +becoming too large and expensive. + +```json +{ + "model": { + "maxSessionTurns": 100 + } +} +``` + +- **`maxSessionTurns`**: (number) The maximum number of turns (user and model + exchanges) allowed in a single session. Set to `-1` for unlimited (default). + + **Behavior when limit is reached:** + + - **Interactive mode:** The CLI shows an informational message and stops + sending requests to the model. You must manually start a new session. + - **Non-interactive mode:** The CLI exits with an error. + +## Next steps + +- Explore the [Memory tool](../tools/memory.md) to save persistent information + across sessions. +- Learn how to [Checkpoint](./checkpointing.md) your session state. +- Check out the [CLI reference](./cli-reference.md) for all command-line flags. diff --git a/docs/cli/settings.md b/docs/cli/settings.md new file mode 100644 index 0000000000000000000000000000000000000000..ba6e0ed3161415bb47177070f71228d94c17efd5 --- /dev/null +++ b/docs/cli/settings.md @@ -0,0 +1,198 @@ +# Gemini CLI settings (`/settings` command) + +Control your Gemini CLI experience with the `/settings` command. The `/settings` +command opens a dialog to view and edit all your Gemini CLI settings, including +your UI experience, keybindings, and accessibility features. + +Your Gemini CLI settings are stored in a `settings.json` file. In addition to +using the `/settings` command, you can also edit them in one of the following +locations: + +- **User settings**: `~/.gemini/settings.json` +- **Workspace settings**: `your-project/.gemini/settings.json` + + +> [!IMPORTANT] +> Workspace settings override user settings. + +## Settings reference + +Here is a list of all the available settings, grouped by category and ordered as +they appear in the UI. + + + +### General + +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Terminal Notifications | `general.enableNotifications` | Enable terminal run-event notifications for action-required prompts and session completion. | `false` | +| Terminal Notification Method | `general.notificationMethod` | How to send terminal notifications. | `"auto"` | +| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` | +| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | +| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | +| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | +| Topic & Update Narration | `general.topicUpdateNarration` | Enable the Topic & Update communication model for reduced chattiness and structured progress reporting. | `true` | +| Log RAG Snippets | `general.logRagSnippets` | Log full Code Customization (RAG) retrieved snippets to a local file for debugging. | `false` | + +### Output + +| UI Label | Setting | Description | Default | +| ------------- | --------------- | ------------------------------------------------------ | -------- | +| Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `"text"` | + +### UI + +| UI Label | Setting | Description | Default | +| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | +| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | +| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | +| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | +| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: β—‡, Action Required: βœ‹, Working: ✦) | `true` | +| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | +| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | +| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | +| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | +| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | +| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `true` | +| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | +| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | +| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | +| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | +| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | +| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | +| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | +| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | +| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | +| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | +| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | +| Render Process | `ui.renderProcess` | Enable Ink render process for the UI. | `true` | +| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` | +| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | +| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | +| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | +| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` | +| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | +| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | + +### IDE + +| UI Label | Setting | Description | Default | +| -------- | ------------- | ---------------------------- | ------- | +| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | + +### Billing + +| UI Label | Setting | Description | Default | +| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | + +### Model + +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | +| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | +| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | +| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | +| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | + +### Agents + +| UI Label | Setting | Description | Default | +| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- | +| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` | +| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` | + +### Context + +| UI Label | Setting | Description | Default | +| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | +| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | +| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | +| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` | +| Custom Ignore File Paths | `context.fileFiltering.customIgnoreFilePaths` | Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. | `[]` | + +### Tools + +| UI Label | Setting | Description | Default | +| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | +| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | +| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | +| Show Color | `tools.shell.showColor` | Show color in shell output. | `true` | +| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | +| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` | +| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` | + +### Security + +| UI Label | Setting | Description | Default | +| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| Tool Sandboxing | `security.toolSandboxing` | Tool-level sandboxing. Isolates individual tools instead of the entire CLI process. | `false` | +| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | +| Disable Always Allow | `security.disableAlwaysAllow` | Disable "Always allow" options in tool confirmation dialogs. | `false` | +| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | +| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` | +| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | +| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` | +| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` | +| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | +| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` | + +### Advanced + +| UI Label | Setting | Description | Default | +| --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` | +| Ignore Local .env | `advanced.ignoreLocalEnv` | Whether to ignore generic .env files in the project directory. | `false` | + +### Experimental + +| UI Label | Setting | Description | Default | +| ---------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` | +| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` | +| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` | +| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` | +| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` | +| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `4000` | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | +| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | +| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | +| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | +| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` | +| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` | +| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `/.inbox//` and held for review in /memory inbox; nothing is applied until you approve it. | `false` | +| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` | +| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` | + +### Skills + +| UI Label | Setting | Description | Default | +| ------------------- | ---------------- | -------------------- | ------- | +| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` | + +### HooksConfig + +| UI Label | Setting | Description | Default | +| ------------------ | --------------------------- | -------------------------------------------------------------------------------- | ------- | +| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` | +| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` | + + diff --git a/docs/cli/skills-best-practices.md b/docs/cli/skills-best-practices.md new file mode 100644 index 0000000000000000000000000000000000000000..071f014fc4fd74d162470d32e2732ba96e4f3e23 --- /dev/null +++ b/docs/cli/skills-best-practices.md @@ -0,0 +1,78 @@ +# Agent Skill best practices + +Create high-quality, reliable Agent Skills by following these established design +principles and patterns. + +## Design for discovery + +The most important part of a skill is its `description`. This is the only +information the model has before activation. + +- **Be specific**: Use keywords that are likely to appear in user prompts (for + example, "audit," "security," "refactor," "migration"). +- **Define the trigger**: Clearly state _when_ the skill should be used (for + example, "Use this skill when the user asks to review a PR for performance + regressions"). +- **Avoid overlap**: Ensure your skill descriptions are distinct from one + another and from the general capabilities of the model. + +## Progressive disclosure + +The "context window" is a shared resource. Use a three-level loading system to +manage context efficiently. + +1. **Metadata (name + description)**: Always in context (~100 words). +2. **`SKILL.md` body**: Loaded only after the skill triggers (<5k words). +3. **Bundled resources**: Loaded only as needed by the model. + +**Best practice**: Keep the `SKILL.md` body focused on core procedural +instructions. Move detailed reference material, schemas, and examples into +separate files in a `references/` directory. + +## Degrees of freedom + +Match the level of instruction specificity to the task's fragility. + +- **High freedom (text-based instructions)**: Use when multiple approaches are + valid or decisions depend heavily on context. +- **Medium freedom (pseudocode or scripts with parameters)**: Use when a + preferred pattern exists but some variation is acceptable. +- **Low freedom (specific scripts, few parameters)**: Use when operations are + fragile and error-prone, or a specific sequence MUST be followed. + +## Bundle resources effectively + +Leverage the skill's ability to include scripts and assets to extend the agent's +capabilities. + +- **Use scripts for deterministic tasks**: If a task can be automated with a + script (for example, running a linter, fetching data from an API), bundle it + in the `scripts/` folder. +- **Agentic ergonomics**: Ensure scripts output LLM-friendly stdout. Suppress + verbose tracebacks and provide clear, concise success/failure messages. +- **Provide templates**: Include common file headers or boilerplate code in the + `assets/` folder to ensure the agent produces consistent output. + +## Anatomy of a great skill + +A well-structured skill directory organizes its resources into specialized +sub-folders. + +```text +my-skill/ +β”œβ”€β”€ SKILL.md (Required) Core instructions and metadata +β”œβ”€β”€ scripts/ (Optional) Executable logic (Node.js, Python, etc.) +β”œβ”€β”€ references/ (Optional) Documentation to be loaded as needed +└── assets/ (Optional) Templates and non-executable resources +``` + +## Security and privacy + +Design your skills with security in mind to protect your workspace and data. + +- **Avoid hardcoded secrets**: Never include API keys or passwords in your + skill's scripts or documentation. +- **Review third-party skills**: Inspect the `SKILL.md` and scripts of any skill + before installing it from an untrusted source. +- **Limit scope**: Design skills to be as focused as possible to minimize the + potential impact of errors. diff --git a/docs/cli/skills.md b/docs/cli/skills.md new file mode 100644 index 0000000000000000000000000000000000000000..5db79048d503ed8c8188310c14b9b3b51be8cba4 --- /dev/null +++ b/docs/cli/skills.md @@ -0,0 +1,141 @@ +# Agent Skills + +Agent Skills let you extend Gemini CLI with specialized expertise, procedural +workflows, and task-specific resources. Based on the +[Agent Skills](https://agentskills.io) open standard, a "skill" is a +self-contained directory that packages instructions and assets into a +discoverable capability. + +Unlike general context files ([GEMINI.md](./gemini-md.md)), which provide +persistent workspace-wide background, Skills represent **on-demand expertise**. +This lets Gemini CLI maintain a vast library of specialized capabilitiesβ€”such as +security auditing, cloud deployments, or codebase migrationsβ€”without cluttering +the model's immediate context window. + +## How it works + +The lifecycle of an Agent Skill involves discovery, activation, and conditional +resource access. + +1. **Discovery**: At the start of a session, Gemini CLI scans the discovery + tiers and injects the name and description of all enabled skills into the + system prompt. +2. **Activation**: When Gemini identifies a task matching a skill's + description, it calls the `activate_skill` tool. +3. **Consent**: You will see a confirmation prompt in the UI detailing the + skill's name, purpose, and the directory path it will gain access to. +4. **Injection**: Upon your approval: + - The `SKILL.md` body and folder structure is added to the conversation + history. + - The skill's directory is added to the agent's allowed file paths, granting + it permission to read any bundled assets. +5. **Execution**: The model proceeds with the specialized expertise active. It + is instructed to prioritize the skill's procedural guidance within reason. + +## Discovery tiers + +Gemini CLI discovers skills from several locations, following a specific order +of precedence (lowest to highest): + +1. **Built-in skills**: Standard skills included with Gemini CLI that provide + foundational capabilities. +2. **Extension skills**: Skills bundled within installed + [extensions](../extensions/index.md). +3. **User skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/` + alias. +4. **Workspace skills**: Located in `.gemini/skills/` or the `.agents/skills/` + alias. Workspace skills are shared with your team via version control. + +### Precedence and aliases + +If multiple skills share the same name, the version from the higher-precedence +location is used. Within the same tier (user or workspace), the +`.agents/skills/` alias takes precedence over the `.gemini/skills/` directory. + +The `.agents/skills/` alias provides an interoperable path for managing +agent-specific expertise that remains compatible across different AI tools. + +## Key benefits + +Agent Skills provide several advantages for managing specialized knowledge and +complex workflows. + +- **Shared expertise**: Package complex workflows (like a specific team's PR + review process) into a folder that anyone can use. +- **Repeatable workflows**: Ensure complex multi-step tasks are performed + consistently by providing a procedural framework. +- **Resource bundling**: Include scripts, templates, or example data alongside + instructions so the agent has everything it needs. +- **Progressive disclosure**: Only skill metadata (name and description) is + loaded initially. Detailed instructions and resources are only disclosed when + the model explicitly activates the skill, saving context tokens. + + +> [!NOTE] +> `/skills disable` and `/skills enable` default to the `user` scope. Use +> `--scope workspace` to manage workspace-specific settings. + +To see all available skills in your current session, use the `/skills list` +command. + +## Managing skills + +You can manage Agent Skills through interactive session commands or directly +from your terminal. + +### In an interactive session + +Use the `/skills` slash command to view and manage available expertise: + +- `/skills list [all] [nodesc]`: Shows discovered skills. Use `all` to include + built-in skills and `nodesc` to hide descriptions. +- `/skills link [--scope user|workspace]`: Links skills from a local + directory. +- `/skills disable `: Prevents a specific skill from being used. +- `/skills enable `: Re-enables a disabled skill. +- `/skills reload` (or `/skills refresh`): Refreshes the list of discovered + skills from all tiers. + +### From the terminal + +The `gemini skills` command provides management utilities: + +```bash +# List all discovered skills. Use --all to include built-in skills. +gemini skills list --all + +# Install a skill from a Git repository or local directory. +# Use --consent to skip the security confirmation prompt. +gemini skills install https://github.com/user/repo.git --consent + +# Uninstall a skill. +gemini skills uninstall my-skill --scope workspace +``` + +#### Command options + +The skill management commands support several global and command-specific +options. + +- `--scope`: Either `user` (global, default) or `workspace` (local to the + project). +- `--path`: The sub-directory within a Git repository containing the skill. +- `--consent`: Acknowledge security risks and skip the interactive confirmation + during installation. + +For more details on CLI commands, see the +[CLI reference](./cli-reference.md#skills-management). + +## Next steps + +Explore these resources to refine your skills and understand the framework +better. + +- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A + quick walkthrough of triggering and using skills. +- [Creating Agent Skills](./creating-skills.md): Create your first skill and + bundle custom logic. +- [Using Agent Skills](./using-agent-skills.md): Learn how to leverage built-in + and custom skills. +- [Best practices](./skills-best-practices.md): Learn strategies for building + effective skills. diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md new file mode 100644 index 0000000000000000000000000000000000000000..1f7fe9bdd6e98af77abe54e156956a76c2cd3703 --- /dev/null +++ b/docs/cli/system-prompt.md @@ -0,0 +1,129 @@ +# System Prompt Override (GEMINI_SYSTEM_MD) + +The core system instructions that guide Gemini CLI can be completely replaced +with your own Markdown file. This feature is controlled via the +`GEMINI_SYSTEM_MD` environment variable. + +## Overview + +The `GEMINI_SYSTEM_MD` variable instructs the CLI to use an external Markdown +file for its system prompt, completely overriding the built-in default. This is +a full replacement, not a merge. If you use a custom file, none of the original +core instructions will apply unless you include them yourself. + +This feature is intended for advanced users who need to enforce strict, +project-specific behavior or create a customized persona. + + +> [!TIP] +> You can export the current default system prompt to a file first, review +> it, and then selectively modify or replace it (see +> [β€œExport the default prompt”](#export-the-default-prompt-recommended)). + +## How to enable + +You can set the environment variable temporarily in your shell, or persist it +via a `.gemini/.env` file. See +[Persisting Environment Variables](../get-started/authentication.mdx#persisting-environment-variables). + +- Use the project default path (`.gemini/system.md`): + + - `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1` + - The CLI reads `./.gemini/system.md` (relative to your current project + directory). + +- Use a custom file path: + + - `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md` + - Relative paths are supported and resolved from the current working + directory. + - Tilde expansion is supported (for example, `~/my-system.md`). + +- Disable the override (use built‑in prompt): + - `GEMINI_SYSTEM_MD=false` or `GEMINI_SYSTEM_MD=0` or unset the variable. + +If the override is enabled but the target file does not exist, the CLI will +error with: `missing system prompt file ''`. + +## Quick examples + +- One‑off session using a project file: + - `GEMINI_SYSTEM_MD=1 gemini` +- Persist for a project using `.gemini/.env`: + - Create `.gemini/system.md`, then add to `.gemini/.env`: + - `GEMINI_SYSTEM_MD=1` +- Use a custom file under your home directory: + - `GEMINI_SYSTEM_MD=~/prompts/system.md gemini` + +## UI indicator + +When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|βŒβ– _β– |` indicator in the UI +to signal custom system‑prompt mode. + +## Variable Substitution + +When using a custom system prompt file, you can use the following variables to +dynamically include built-in content: + +- `${AgentSkills}`: Injects a complete section (including header) of all + available agent skills. +- `${SubAgents}`: Injects a complete section (including header) of available + sub-agents. +- `${AvailableTools}`: Injects a bulleted list of all currently enabled tool + names. +- Tool Name Variables: Injects the actual name of a tool using the pattern: + `${toolName}_ToolName` (for example, `${write_file_ToolName}`, + `${run_shell_command_ToolName}`). + + This pattern is generated dynamically for all available tools. + +### Example + +```markdown +# Custom System Prompt + +You are a helpful assistant. ${AgentSkills} +${SubAgents} + +## Tooling + +The following tools are available to you: ${AvailableTools} + +You can use ${write_file_ToolName} to save logs. +``` + +## Export the default prompt (recommended) + +Before overriding, export the current default prompt so you can review required +safety and workflow rules. + +- Write the built‑in prompt to the project default path: + - `GEMINI_WRITE_SYSTEM_MD=1 gemini` +- Or write to a custom path: + - `GEMINI_WRITE_SYSTEM_MD=~/prompts/DEFAULT_SYSTEM.md gemini` + +This creates the file and writes the current built‑in system prompt to it. + +## Best practices: system.md vs GEMINI.md + +- system.md (firmware): + - Non‑negotiable operational rules: safety, tool‑use protocols, approvals, and + mechanics that keep the CLI reliable. + - Stable across tasks and projects (or per project when needed). +- GEMINI.md (strategy): + - Persona, goals, methodologies, and project/domain context. + - Evolves per task; relies on system.md for safe execution. + +Keep system.md minimal but complete for safety and tool operation. Keep +GEMINI.md focused on high‑level guidance and project specifics. + +## Troubleshooting + +- Error: `missing system prompt file '…'` + - Ensure the referenced path exists and is readable. + - For `GEMINI_SYSTEM_MD=1|true`, create `./.gemini/system.md` in your project. +- Override not taking effect + - Confirm the variable is loaded (use `.gemini/.env` or export in your shell). + - Paths are resolved from the current working directory; try an absolute path. +- Restore defaults + - Unset `GEMINI_SYSTEM_MD` or set it to `0`/`false`. diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md new file mode 100644 index 0000000000000000000000000000000000000000..a1d44eeb23518945f6bb5d647161bedd84602fa4 --- /dev/null +++ b/docs/cli/telemetry.md @@ -0,0 +1,1277 @@ +# Observability with OpenTelemetry + +Observability is the key to turning experimental AI into reliable software. +Gemini CLI provides built-in support for OpenTelemetry, transforming every agent +interaction into a rich stream of logs, metrics, and traces. This three-pillar +approach gives you the high-fidelity visibility needed to understand agent +behavior, optimize performance, and ensure reliability across your entire +workflow. + +Whether you are debugging a complex tool interaction locally or monitoring +enterprise-wide usage in the cloud, Gemini CLI's observability system provides +the actionable intelligence needed to move from "black box" AI to predictable, +high-performance systems. + +## OpenTelemetry integration + +Gemini CLI integrates with **[OpenTelemetry]**, a vendor-neutral, +industry-standard observability framework. + +The observability system provides: + +- Universal compatibility: Export to any OpenTelemetry backend (Google Cloud, + Jaeger, Prometheus, Datadog, etc.). +- Standardized data: Use consistent formats and collection methods across your + toolchain. +- Future-proof integration: Connect with existing and future observability + infrastructure. +- No vendor lock-in: Switch between backends without changing your + instrumentation. + +[OpenTelemetry]: https://opentelemetry.io/ + +## Configuration + +You control telemetry behavior through the `.gemini/settings.json` file. +Environment variables can override these settings. + +| Setting | Environment Variable | Description | Values | Default | +| -------------- | --------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- | +| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` | +| `traces` | `GEMINI_TELEMETRY_TRACES_ENABLED` | Enable detailed attribute tracing | `true`/`false` | `false` | +| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | +| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` | +| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | +| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | +| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` | +| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` | +| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` | +| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - | + +**Note on boolean environment variables:** For boolean settings like `enabled`, +setting the environment variable to `true` or `1` enables the feature. + +For detailed configuration information, see the +[Configuration guide](../reference/configuration.md). + +## Google Cloud telemetry + +You can export telemetry data directly to Google Cloud Trace, Cloud Monitoring, +and Cloud Logging. + +### Prerequisites + +You must complete several setup steps before enabling Google Cloud telemetry. + +1. Set your Google Cloud project ID: + + - To send telemetry to a separate project: + + **macOS/Linux** + + ```bash + export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` + + **Windows (PowerShell)** + + ```powershell + $env:OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` + + - To send telemetry to the same project as inference: + + **macOS/Linux** + + ```bash + export GOOGLE_CLOUD_PROJECT="your-project-id" + ``` + + **Windows (PowerShell)** + + ```powershell + $env:GOOGLE_CLOUD_PROJECT="your-project-id" + ``` + +2. Authenticate with Google Cloud using one of these methods: + + - **Method A: Application Default Credentials (ADC)**: Use this method for + service accounts or standard `gcloud` authentication. + + - For user accounts: + ```bash + gcloud auth application-default login + ``` + - For service accounts: + + **macOS/Linux** + + ```bash + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" + ``` + + **Windows (PowerShell)** + + ```powershell + $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json" + ``` + + * **Method B: CLI Auth** (Direct export only): Simplest method for local + users. Gemini CLI uses the same OAuth credentials you used for login. To + enable this, set `useCliAuth: true` in your `.gemini/settings.json`: + + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp", + "useCliAuth": true + } + } + ``` + + +> [!NOTE] +> This setting requires **Direct export** (in-process exporters) +> and cannot be used when `useCollector` is `true`. If both are enabled, +> telemetry will be disabled. + +3. Ensure your account or service account has these IAM roles: + + - Cloud Trace Agent + - Monitoring Metric Writer + - Logs Writer + +4. Enable the required Google Cloud APIs: + ```bash + gcloud services enable \ + cloudtrace.googleapis.com \ + monitoring.googleapis.com \ + logging.googleapis.com \ + --project="$OTLP_GOOGLE_CLOUD_PROJECT" + ``` + +### Direct export + +We recommend using direct export to send telemetry directly to Google Cloud +services. + +1. Enable telemetry in `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp" + } + } + ``` +2. Run Gemini CLI and send prompts. +3. View logs, metrics, and traces in the Google Cloud Console. See + [View Google Cloud telemetry](#view-google-cloud-telemetry) for details. + +### View Google Cloud telemetry + +After you enable telemetry and run Gemini CLI, you can view your data in the +Google Cloud Console. + +- **Logs:** [Logs Explorer](https://console.cloud.google.com/logs/) +- **Metrics:** + [Metrics Explorer](https://console.cloud.google.com/monitoring/metrics-explorer) +- **Traces:** [Trace Explorer](https://console.cloud.google.com/traces/list) + +For detailed information on how to use these tools, see the following official +Google Cloud documentation: + +- [View and analyze logs with Logs Explorer](https://cloud.google.com/logging/docs/view/logs-explorer-interface) +- [Create charts with Metrics Explorer](https://cloud.google.com/monitoring/charts/metrics-explorer) +- [Find and explore traces](https://cloud.google.com/trace/docs/finding-traces) + +#### Monitoring dashboards + +Gemini CLI provides a pre-configured +[Google Cloud Monitoring](https://cloud.google.com/monitoring) dashboard to +visualize your telemetry. + +Find this dashboard under **Google Cloud Monitoring Dashboard Templates** as +"**Gemini CLI Monitoring**". + +![Gemini CLI Monitoring Dashboard Overview](/docs/assets/monitoring-dashboard-overview.png) + +![Gemini CLI Monitoring Dashboard Metrics](/docs/assets/monitoring-dashboard-metrics.png) + +![Gemini CLI Monitoring Dashboard Logs](/docs/assets/monitoring-dashboard-logs.png) + +To learn more, see +[Instant insights: Gemini CLI’s pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/). + +## Local telemetry + +You can capture telemetry data locally for development and debugging. We +recommend using file-based output for local development. + +1. Enable telemetry in `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".gemini/telemetry.log" + } + } + ``` +2. Run Gemini CLI and send prompts. +3. View logs and metrics in `.gemini/telemetry.log`. + +For advanced local telemetry setups (such as Jaeger or Genkit), see the +[Local development guide](../local-development.md#viewing-traces). + +## Client identification + +Gemini CLI includes identifiers in its `User-Agent` header to help you +differentiate and report on API traffic from different environments (for +example, identifying calls from Gemini Code Assist versus a standard terminal). + +### Automatic identification + +Most integrated environments are identified automatically without additional +configuration. The identifier is included as a prefix to the `User-Agent` and as +a "surface" tag in the parenthetical metadata. + +| Environment | User-Agent Prefix | Surface Tag | +| :---------------------------------- | :--------------------------- | :---------- | +| **Gemini Code Assist (Agent Mode)** | `GeminiCLI-a2a-server` | `vscode` | +| **Zed (via ACP)** | `GeminiCLI-acp-zed` | `zed` | +| **XCode (via ACP)** | `GeminiCLI-acp-xcode` | `xcode` | +| **IntelliJ IDEA (via ACP)** | `GeminiCLI-acp-intellijidea` | `jetbrains` | +| **Standard Terminal** | `GeminiCLI` | `terminal` | + +**Example User-Agent:** +`GeminiCLI-a2a-server/0.34.0/gemini-pro (linux; x64; vscode)` + +### Custom identification + +You can provide a custom identifier for your own scripts or automation by +setting the `GEMINI_CLI_SURFACE` environment variable. This is useful for +tracking specific internal tools or distribution channels in your GCP logs. + +**macOS/Linux** + +```bash +export GEMINI_CLI_SURFACE="my-custom-tool" +``` + +**Windows (PowerShell)** + +```powershell +$env:GEMINI_CLI_SURFACE="my-custom-tool" +``` + +When set, the value appears at the end of the `User-Agent` parenthetical: +`GeminiCLI/0.34.0/gemini-pro (linux; x64; my-custom-tool)` + +## Logs, metrics, and traces + +This section describes the structure of logs, metrics, and traces generated by +Gemini CLI. + +Gemini CLI includes `session.id`, `installation.id`, `active_approval_mode`, and +`user.email` (when authenticated) as common attributes on all data. + +### Logs + +Logs provide timestamped records of specific events. Gemini CLI logs events +across several categories. + +#### Sessions + +Session logs capture startup configuration and prompt submissions. + +##### `gemini_cli.config` + +Emitted at startup with the CLI configuration. + +
+Attributes + +- `model` (string) +- `embedding_model` (string) +- `sandbox_enabled` (boolean) +- `core_tools_enabled` (string) +- `approval_mode` (string) +- `api_key_enabled` (boolean) +- `vertex_ai_enabled` (boolean) +- `log_user_prompts_enabled` (boolean) +- `file_filtering_respect_git_ignore` (boolean) +- `debug_mode` (boolean) +- `mcp_servers` (string) +- `mcp_servers_count` (int) +- `mcp_tools` (string) +- `mcp_tools_count` (int) +- `output_format` (string) +- `extensions` (string) +- `extension_ids` (string) +- `extensions_count` (int) +- `auth_type` (string) +- `worktree_active` (boolean) +- `github_workflow_name` (string, optional) +- `github_repository_hash` (string, optional) +- `github_event_name` (string, optional) +- `github_pr_number` (string, optional) +- `github_issue_number` (string, optional) +- `github_custom_tracking_id` (string, optional) + +
+ +##### `gemini_cli.user_prompt` + +Emitted when you submit a prompt. + +
+Attributes + +- `prompt_length` (int) +- `prompt_id` (string) +- `prompt` (string; excluded if `telemetry.logPrompts` is `false`) +- `auth_type` (string) + +
+ +#### Approval mode + +These logs track changes to and usage of different approval modes. + +##### Lifecycle + +##### `approval_mode_switch` + +Logs when you change the approval mode. + +
+Attributes + +- `from_mode` (string) +- `to_mode` (string) + +
+ +##### `approval_mode_duration` + +Records time spent in an approval mode. + +
+Attributes + +- `mode` (string) +- `duration_ms` (int) + +
+ +##### Execution + +##### `plan_execution` + +Logs when you execute a plan and switch from plan mode to active execution. + +
+Attributes + +- `approval_mode` (string) + +
+ +#### Tools + +Tool logs capture executions, truncation, and edit behavior. + +##### `gemini_cli.tool_call` + +Emitted for each tool (function) call. + +
+Attributes + +- `function_name` (string) +- `function_args` (string) +- `duration_ms` (int) +- `success` (boolean) +- `decision` (string: "accept", "reject", "auto_accept", or "modify") +- `error` (string, optional) +- `error_type` (string, optional) +- `prompt_id` (string) +- `tool_type` (string: "native" or "mcp") +- `mcp_server_name` (string, optional) +- `extension_name` (string, optional) +- `extension_id` (string, optional) +- `content_length` (int, optional) +- `start_time` (number, optional) +- `end_time` (number, optional) +- `metadata` (object, optional), which may include: + - `model_added_lines` (number) + - `model_removed_lines` (number) + - `user_added_lines` (number) + - `user_removed_lines` (number) + - `ask_user` (object) + +
+ +##### `gemini_cli.tool_output_truncated` + +Logs when tool output is truncated. + +
+Attributes + +- `tool_name` (string) +- `original_content_length` (int) +- `truncated_content_length` (int) +- `threshold` (int) +- `lines` (int) +- `prompt_id` (string) + +
+ +##### `gemini_cli.edit_strategy` + +Records the chosen edit strategy. + +
+Attributes + +- `strategy` (string) + +
+ +##### `gemini_cli.edit_correction` + +Records the result of an edit correction. + +
+Attributes + +- `correction` (string: "success" or "failure") + +
+ +##### `gen_ai.client.inference.operation.details` + +Provides detailed GenAI operation data aligned with OpenTelemetry conventions. + +
+Attributes + +- `gen_ai.request.model` (string) +- `gen_ai.provider.name` (string) +- `gen_ai.operation.name` (string) +- `gen_ai.input.messages` (json string) +- `gen_ai.output.messages` (json string) +- `gen_ai.response.finish_reasons` (array of strings) +- `gen_ai.usage.input_tokens` (int) +- `gen_ai.usage.output_tokens` (int) +- `gen_ai.request.temperature` (float) +- `gen_ai.request.top_p` (float) +- `gen_ai.request.top_k` (int) +- `gen_ai.request.max_tokens` (int) +- `gen_ai.system_instructions` (json string) +- `server.address` (string) +- `server.port` (int) + +
+ +#### Files + +File logs track operations performed by tools. + +##### `gemini_cli.file_operation` + +Emitted for each file creation, read, or update. + +
+Attributes + +- `tool_name` (string) +- `operation` (string: "create", "read", or "update") +- `lines` (int, optional) +- `mimetype` (string, optional) +- `extension` (string, optional) +- `programming_language` (string, optional) + +
+ +#### API + +API logs capture requests, responses, and errors from Gemini API. + +##### `gemini_cli.api_request` + +Request sent to Gemini API. + +
+Attributes + +- `model` (string) +- `prompt_id` (string) +- `role` (string: "user", "model", or "system") +- `request_text` (string, optional) + +
+ +##### `gemini_cli.api_response` + +Response received from Gemini API. + +
+Attributes + +- `model` (string) +- `status_code` (int or string) +- `duration_ms` (int) +- `input_token_count` (int) +- `output_token_count` (int) +- `cached_content_token_count` (int) +- `thoughts_token_count` (int) +- `tool_token_count` (int) +- `total_token_count` (int) +- `prompt_id` (string) +- `auth_type` (string) +- `finish_reasons` (array of strings) +- `response_text` (string, optional) + +
+ +##### `gemini_cli.api_error` + +Logs when an API request fails. + +
+Attributes + +- `error.message` (string) +- `model_name` (string) +- `duration` (int) +- `prompt_id` (string) +- `auth_type` (string) +- `error_type` (string, optional) +- `status_code` (int or string, optional) +- `role` (string, optional) + +
+ +##### `gemini_cli.malformed_json_response` + +Logs when a JSON response cannot be parsed. + +
+Attributes + +- `model` (string) + +
+ +#### Model routing + +These logs track how Gemini CLI selects and routes requests to models. + +##### `gemini_cli.slash_command` + +Logs slash command execution. + +
+Attributes + +- `command` (string) +- `subcommand` (string, optional) +- `status` (string: "success" or "error") + +
+ +##### `gemini_cli.slash_command.model` + +Logs model selection via slash command. + +
+Attributes + +- `model_name` (string) + +
+ +##### `gemini_cli.model_routing` + +Records model router decisions and reasoning. + +
+Attributes + +- `decision_model` (string) +- `decision_source` (string) +- `routing_latency_ms` (int) +- `reasoning` (string, optional) +- `failed` (boolean) +- `error_message` (string, optional) +- `approval_mode` (string) + +
+ +#### Chat and streaming + +These logs track chat context compression and streaming chunk errors. + +##### `gemini_cli.chat_compression` + +Logs chat context compression events. + +
+Attributes + +- `tokens_before` (int) +- `tokens_after` (int) + +
+ +##### `gemini_cli.chat.invalid_chunk` + +Logs invalid chunks received in a stream. + +
+Attributes + +- `error_message` (string, optional) + +
+ +##### `gemini_cli.chat.content_retry` + +Logs retries due to content errors. + +
+Attributes + +- `attempt_number` (int) +- `error_type` (string) +- `retry_delay_ms` (int) +- `model` (string) + +
+ +##### `gemini_cli.chat.content_retry_failure` + +Logs when all content retries fail. + +
+Attributes + +- `total_attempts` (int) +- `final_error_type` (string) +- `total_duration_ms` (int, optional) +- `model` (string) + +
+ +##### `gemini_cli.conversation_finished` + +Logs when a conversation session ends. + +
+Attributes + +- `approvalMode` (string) +- `turnCount` (int) + +
+ +#### Resilience + +Resilience logs record fallback mechanisms and recovery attempts. + +##### `gemini_cli.flash_fallback` + +Logs switch to a flash model fallback. + +
+Attributes + +- `auth_type` (string) + +
+ +##### `gemini_cli.ripgrep_fallback` + +Logs fallback to standard grep. + +
+Attributes + +- `error` (string, optional) + +
+ +##### `gemini_cli.web_fetch_fallback_attempt` + +Logs web-fetch fallback attempts. + +
+Attributes + +- `reason` (string: "private_ip" or "primary_failed") + +
+ +##### `gemini_cli.agent.recovery_attempt` + +Logs attempts to recover from agent errors. + +
+Attributes + +- `agent_name` (string) +- `attempt_number` (int) +- `success` (boolean) +- `error_type` (string, optional) + +
+ +#### Extensions + +Extension logs track lifecycle events and settings changes. + +##### `gemini_cli.extension_install` + +Logs when you install an extension. + +
+Attributes + +- `extension_name` (string) +- `extension_version` (string) +- `extension_source` (string) +- `status` (string) + +
+ +##### `gemini_cli.extension_uninstall` + +Logs when you uninstall an extension. + +
+Attributes + +- `extension_name` (string) +- `status` (string) + +
+ +##### `gemini_cli.extension_enable` + +Logs when you enable an extension. + +
+Attributes + +- `extension_name` (string) +- `setting_scope` (string) + +
+ +##### `gemini_cli.extension_disable` + +Logs when you disable an extension. + +
+Attributes + +- `extension_name` (string) +- `setting_scope` (string) + +
+ +#### Agent runs + +Agent logs track the lifecycle of agent executions. + +##### `gemini_cli.agent.start` + +Logs when an agent run begins. + +
+Attributes + +- `agent_id` (string) +- `agent_name` (string) + +
+ +##### `gemini_cli.agent.finish` + +Logs when an agent run completes. + +
+Attributes + +- `agent_id` (string) +- `agent_name` (string) +- `duration_ms` (int) +- `turn_count` (int) +- `terminate_reason` (string) + +
+ +#### IDE + +IDE logs capture connectivity events for the IDE companion. + +##### `gemini_cli.ide_connection` + +Logs IDE companion connections. + +
+Attributes + +- `connection_type` (string) + +
+ +#### UI + +UI logs track terminal rendering issues. + +##### `kitty_sequence_overflow` + +Logs terminal control sequence overflows. + +
+Attributes + +- `sequence_length` (int) +- `truncated_sequence` (string) + +
+ +#### Miscellaneous + +##### `gemini_cli.rewind` + +Logs when the conversation state is rewound. + +
+Attributes + +- `outcome` (string) + +
+ +##### `gemini_cli.conseca.verdict` + +Logs security verdicts from ConSeca. + +
+Attributes + +- `verdict` (string) +- `decision` (string: "accept", "reject", or "modify") +- `reason` (string, optional) +- `tool_name` (string, optional) + +
+ +##### `gemini_cli.hook_call` + +Logs execution of lifecycle hooks. + +
+Attributes + +- `hook_name` (string) +- `hook_type` (string) +- `duration_ms` (int) +- `success` (boolean) + +
+ +##### `gemini_cli.tool_output_masking` + +Logs when tool output is masked for privacy. + +
+Attributes + +- `tokens_before` (int) +- `tokens_after` (int) +- `masked_count` (int) +- `total_prunable_tokens` (int) + +
+ +##### `gemini_cli.keychain.availability` + +Logs keychain availability checks. + +
+Attributes + +- `available` (boolean) + +##### `gemini_cli.startup_stats` + +Logs detailed startup performance statistics. + +
+Attributes + +- `phases` (json array of startup phases) +- `os_platform` (string) +- `os_release` (string) +- `is_docker` (boolean) + +
+ +
+ +### Metrics + +Metrics provide numerical measurements of behavior over time. + +#### Custom metrics + +Gemini CLI exports several custom metrics. + +##### Sessions + +##### `gemini_cli.session.count` + +Incremented once per CLI startup. + +##### Onboarding + +Tracks onboarding flow from authentication to the user + +- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the + authentication flow begins. + +- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user +onboarding flow completes successfully. +
+Attributes (Success) + +- `user_tier` (string) + +##### Tools + +##### `gemini_cli.tool.call.count` + +Counts tool calls. + +
+Attributes + +- `function_name` (string) +- `success` (boolean) +- `decision` (string: "accept", "reject", "modify", or "auto_accept") +- `tool_type` (string: "mcp" or "native") + +
+ +##### `gemini_cli.tool.call.latency` + +Measures tool call latency (in ms). + +
+Attributes + +- `function_name` (string) + +
+ +##### API + +##### `gemini_cli.api.request.count` + +Counts all API requests. + +
+Attributes + +- `model` (string) +- `status_code` (int or string) +- `error_type` (string, optional) + +
+ +##### `gemini_cli.api.request.latency` + +Measures API request latency (in ms). + +
+Attributes + +- `model` (string) + +
+ +##### Token usage + +##### `gemini_cli.token.usage` + +Counts input, output, thought, cache, and tool tokens. + +
+Attributes + +- `model` (string) +- `type` (string: "input", "output", "thought", "cache", or "tool") + +
+ +##### Files + +##### `gemini_cli.file.operation.count` + +Counts file operations. + +
+Attributes + +- `operation` (string: "create", "read", or "update") +- `lines` (int, optional) +- `mimetype` (string, optional) +- `extension` (string, optional) +- `programming_language` (string, optional) + +
+ +##### `gemini_cli.lines.changed` + +Counts added or removed lines. + +
+Attributes + +- `function_name` (string, optional) +- `type` (string: "added" or "removed") + +
+ +##### Chat and streaming + +##### `gemini_cli.chat_compression` + +Counts compression operations. + +
+Attributes + +- `tokens_before` (int) +- `tokens_after` (int) + +
+ +##### `gemini_cli.chat.invalid_chunk.count` + +Counts invalid stream chunks. + +##### `gemini_cli.chat.content_retry.count` + +Counts content error retries. + +##### `gemini_cli.chat.content_retry_failure.count` + +Counts requests where all retries failed. + +##### Model routing + +##### `gemini_cli.slash_command.model.call_count` + +Counts model selections. + +
+Attributes + +- `slash_command.model.model_name` (string) + +
+ +##### `gemini_cli.model_routing.latency` + +Measures routing decision latency. + +
+Attributes + +- `routing.decision_model` (string) +- `routing.decision_source` (string) +- `routing.approval_mode` (string) + +
+ +##### `gemini_cli.model_routing.failure.count` + +Counts routing failures. + +
+Attributes + +- `routing.decision_source` (string) +- `routing.error_message` (string) +- `routing.approval_mode` (string) + +
+ +##### Agent runs + +##### `gemini_cli.agent.run.count` + +Counts agent runs. + +
+Attributes + +- `agent_name` (string) +- `terminate_reason` (string) + +
+ +##### `gemini_cli.agent.duration` + +Measures agent run duration. + +
+Attributes + +- `agent_name` (string) + +
+ +##### `gemini_cli.agent.turns` + +Counts turns per agent run. + +
+Attributes + +- `agent_name` (string) + +
+ +##### Approval mode + +##### `gemini_cli.plan.execution.count` + +Counts plan executions. + +
+Attributes + +- `approval_mode` (string) + +
+ +##### UI + +##### `gemini_cli.ui.flicker.count` + +Counts terminal flicker events. + +##### Performance + +Gemini CLI provides detailed performance metrics for advanced monitoring. + +##### `gemini_cli.startup.duration` + +Measures startup time by phase. + +
+Attributes + +- `phase` (string) +- `details` (map, optional) + +
+ +##### `gemini_cli.memory.usage` + +Measures heap and RSS memory. + +
+Attributes + +- `memory_type` (string: "heap_used", "heap_total", "external", "rss") +- `component` (string, optional) + +
+ +##### `gemini_cli.cpu.usage` + +Measures CPU usage percentage. + +
+Attributes + +- `component` (string, optional) + +
+ +##### `gemini_cli.tool.queue.depth` + +Measures tool execution queue depth. + +##### `gemini_cli.tool.execution.breakdown` + +Breaks down tool time by phase. + +
+Attributes + +- `function_name` (string) +- `phase` (string: "validation", "preparation", "execution", + "result_processing") + +
+ +#### GenAI semantic convention + +These metrics follow standard [OpenTelemetry GenAI semantic conventions]. + +- `gen_ai.client.token.usage`: Counts tokens used per operation. +- `gen_ai.client.operation.duration`: Measures operation duration in seconds. + +[OpenTelemetry GenAI semantic conventions]: + https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md + +### Traces + +Traces provide an "under-the-hood" view of agent and backend operations. Use +traces to debug tool interactions and optimize performance. + + +> [!NOTE] +> Detailed trace attributes (like full prompts and tool outputs) are disabled by default +> to minimize overhead. You must explicitly set `telemetry.traces` to `true` (or set +> `GEMINI_TELEMETRY_TRACES_ENABLED=true`) to capture them. + +Every trace captures rich metadata via standard span attributes. + +
+Standard span attributes + +- `gen_ai.operation.name`: High-level operation (for example, `tool_call`, + `llm_call`, `user_prompt`, `system_prompt`, `agent_call`, or + `schedule_tool_calls`). +- `gen_ai.agent.name`: Set to `gemini-cli`. +- `gen_ai.agent.description`: The service agent description. +- `gen_ai.input.messages`: Input data or metadata. +- `gen_ai.output.messages`: Output data or results. +- `gen_ai.request.model`: Request model name. +- `gen_ai.response.model`: Response model name. +- `gen_ai.prompt.name`: The prompt name. +- `gen_ai.tool.name`: Executed tool name. +- `gen_ai.tool.call_id`: Unique ID for the tool call. +- `gen_ai.tool.description`: Tool description. +- `gen_ai.tool.definitions`: Tool definitions in JSON format. +- `gen_ai.usage.input_tokens`: Number of input tokens. +- `gen_ai.usage.output_tokens`: Number of output tokens. +- `gen_ai.system_instructions`: System instructions in JSON format. +- `gen_ai.conversation.id`: The CLI session ID. + +
+ +For more details on semantic conventions for events, see the +[OpenTelemetry documentation](https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md). diff --git a/docs/cli/themes.md b/docs/cli/themes.md new file mode 100644 index 0000000000000000000000000000000000000000..9a3d628c20a745ffdc22cd239fda2b989cbefee4 --- /dev/null +++ b/docs/cli/themes.md @@ -0,0 +1,288 @@ +# Themes + +Gemini CLI supports a variety of themes to customize its color scheme and +appearance. You can change the theme to suit your preferences via the `/theme` +command or `"theme":` configuration setting. + +## Available themes + +Gemini CLI comes with a selection of pre-defined themes, which you can list +using the `/theme` command within Gemini CLI: + +- **Dark themes:** + - `ANSI` + - `Atom One` + - `Ayu` + - `Default` + - `Dracula` + - `GitHub` + - `Holiday` + - `Shades Of Purple` + - `Solarized Dark` + - `Tokyo Night` +- **Light themes:** + - `ANSI Light` + - `Ayu Light` + - `Default Light` + - `GitHub Light` + - `Google Code` + - `Solarized Light` + - `Xcode` + +### Changing themes + +1. Enter `/theme` into Gemini CLI. +2. A dialog or selection prompt appears, listing the available themes. +3. Using the arrow keys, select a theme. Some interfaces might offer a live + preview or highlight as you select. +4. Confirm your selection to apply the theme. + + +> [!NOTE] +> If a theme is defined in your `settings.json` file (either by name or +> by a file path), you must remove the `"theme"` setting from the file before +> you can change the theme using the `/theme` command. + +### Theme persistence + +Selected themes are saved in Gemini CLI's +[configuration](../reference/configuration.md) so your preference is remembered +across sessions. + +--- + +## Custom color themes + +Gemini CLI lets you create your own custom color themes by specifying them in +your `settings.json` file. This gives you full control over the color palette +used in the CLI. + +### How to define a custom theme + +Add a `customThemes` block to your user, project, or system `settings.json` +file. Each custom theme is defined as an object with a unique name and a set of +nested configuration objects. For example: + +```json +{ + "ui": { + "customThemes": { + "MyCustomTheme": { + "name": "MyCustomTheme", + "type": "custom", + "background": { + "primary": "#181818" + }, + "text": { + "primary": "#f0f0f0", + "secondary": "#a0a0a0" + } + } + } + } +} +``` + +**Configuration objects:** + +- **`text`**: Defines text colors. + - `primary`: The default text color. + - `secondary`: Used for less prominent text. + - `link`: Color for URLs and links. + - `accent`: Used for highlights and emphasis. + - `response`: Precedence over `primary` for rendering model responses. +- **`background`**: Defines background colors. + - `primary`: The main background color of the UI. + - `diff.added`: Background for added lines in diffs. + - `diff.removed`: Background for removed lines in diffs. +- **`border`**: Defines border colors. + - `default`: The standard border color. + - `focused`: Border color when an element is focused. +- **`status`**: Colors for status indicators. + - `success`: Used for successful operations. + - `warning`: Used for warnings. + - `error`: Used for errors. +- **`ui`**: Other UI elements. + - `comment`: Color for code comments. + - `symbol`: Color for code symbols and operators. + - `gradient`: An array of colors used for gradient effects. + +**Required properties:** + +- `name` (must match the key in the `customThemes` object and be a string) +- `type` (must be the string `"custom"`) + +While all sub-properties are technically optional, we recommend providing at +least `background.primary`, `text.primary`, `text.secondary`, and the various +accent colors via `text.link`, `text.accent`, and `status` to ensure a cohesive +UI. + +You can use either hex codes (for example, `#FF0000`) **or** standard CSS color +names (for example, `coral`, `teal`, `blue`) for any color value. See +[CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#color_keywords) +for a full list of supported names. + +You can define multiple custom themes by adding more entries to the +`customThemes` object. + +### Loading themes from a file + +In addition to defining custom themes in `settings.json`, you can also load a +theme directly from a JSON file by specifying the file path in your +`settings.json`. This is useful for sharing themes or keeping them separate from +your main configuration. + +To load a theme from a file, set the `theme` property in your `settings.json` to +the path of your theme file: + +```json +{ + "ui": { + "theme": "/path/to/your/theme.json" + } +} +``` + +The theme file must be a valid JSON file that follows the same structure as a +custom theme defined in `settings.json`. + +**Example `my-theme.json`:** + +```json +{ + "name": "Gruvbox Dark", + "type": "custom", + "background": { + "primary": "#282828", + "diff": { + "added": "#2b3312", + "removed": "#341212" + } + }, + "text": { + "primary": "#ebdbb2", + "secondary": "#a89984", + "link": "#83a598", + "accent": "#d3869b" + }, + "border": { + "default": "#3c3836", + "focused": "#458588" + }, + "status": { + "success": "#b8bb26", + "warning": "#fabd2f", + "error": "#fb4934" + }, + "ui": { + "comment": "#928374", + "symbol": "#8ec07c", + "gradient": ["#cc241d", "#d65d0e", "#d79921"] + } +} +``` + + +> [!WARNING] +> For your safety, Gemini CLI will only load theme files that +> are located within your home directory. If you attempt to load a theme from +> outside your home directory, a warning will be displayed and the theme will +> not be loaded. This is to prevent loading potentially malicious theme files +> from untrusted sources. + +### Example custom theme + +Custom theme example + +### Using your custom theme + +- Select your custom theme using the `/theme` command in Gemini CLI. Your custom + theme will appear in the theme selection dialog. +- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` + object in your `settings.json`. +- Custom themes can be set at the user, project, or system level, and follow the + same [configuration precedence](../reference/configuration.md) as other + settings. + +### Themes from extensions + +[Extensions](../extensions/reference.md#themes) can also provide custom themes. +Once an extension is installed and enabled, its themes are automatically added +to the selection list in the `/theme` command. + +Themes from extensions appear with the extension name in parentheses to help you +identify their source, for example: `shades-of-green (green-extension)`. + +--- + +## Dark themes + +### ANSI + +ANSI theme + +### Atom One + +Atom One theme + +### Ayu + +Ayu theme + +### Default + +Default theme + +### Dracula + +Dracula theme + +### GitHub + +GitHub theme + +### Holiday + +Holiday theme + +### Shades Of Purple + +Shades Of Purple theme + +### Solarized Dark + +Solarized Dark theme + +### Tokyo Night + +Tokyo Night theme + +## Light themes + +### ANSI Light + +ANSI Light theme + +### Ayu Light + +Ayu Light theme + +### Default Light + +Default Light theme + +### GitHub Light + +GitHub Light theme + +### Google Code + +Google Code theme + +### Solarized Light + +Solarized Light theme + +### Xcode + +Xcode Light theme diff --git a/docs/cli/token-caching.md b/docs/cli/token-caching.md new file mode 100644 index 0000000000000000000000000000000000000000..8d56391b0a3d2aa8b76bc3e1ef95920082ac6beb --- /dev/null +++ b/docs/cli/token-caching.md @@ -0,0 +1,20 @@ +# Token caching and cost optimization + +Gemini CLI automatically optimizes API costs through token caching when using +API key authentication (Gemini API key or Vertex AI). This feature reuses +previous system instructions and context to reduce the number of tokens +processed in subsequent requests. + +**Token caching is available for:** + +- API key users (Gemini API key) +- Vertex AI users (with project and location setup) + +**Token caching is not available for:** + +- OAuth users (Google Personal/Enterprise accounts) - the Code Assist API does + not support cached content creation at this time + +You can view your token usage and cached token savings using the `/stats` +command. When cached tokens are available, they will be displayed in the stats +output. diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md new file mode 100644 index 0000000000000000000000000000000000000000..cbcf5b7fd09848b63b8305ebd87762838143096f --- /dev/null +++ b/docs/cli/trusted-folders.md @@ -0,0 +1,154 @@ +# Trusted Folders + +The Trusted Folders feature is a security setting that gives you control over +which projects can use the full capabilities of Gemini CLI. It prevents +potentially malicious code from running by asking you to approve a folder before +the CLI loads any project-specific configurations from it. + +## Enabling the feature + +The Trusted Folders feature is **disabled by default**. To use it, you must +first enable it in your settings. + +Add the following to your user `settings.json` file: + +```json +{ + "security": { + "folderTrust": { + "enabled": true + } + } +} +``` + +## How it works: The trust dialog + +Once the feature is enabled, the first time you run Gemini CLI from a folder, a +dialog will automatically appear, prompting you to make a choice: + +- **Trust folder**: Grants full trust to the current folder (for example, + `my-project`). +- **Trust parent folder**: Grants trust to the parent directory (for example, + `safe-projects`), which automatically trusts all of its subdirectories as + well. This is useful if you keep all your safe projects in one place. +- **Don't trust**: Marks the folder as untrusted. The CLI will operate in a + restricted "safe mode." + +Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you +will only be asked once per folder. + +## Understanding folder contents: The discovery phase + +Before you make a choice, Gemini CLI performs a **discovery phase** to scan the +folder for potential configurations. This information is displayed in the trust +dialog to help you make an informed decision. + +The discovery UI lists the following categories of items found in the project: + +- **Commands**: Custom `.toml` command definitions that add new functionality. +- **MCP Servers**: Configured Model Context Protocol servers that the CLI will + attempt to connect to. +- **Hooks**: System or custom hooks that can intercept and modify CLI behavior. +- **Skills**: Local agent skills that provide specialized capabilities. +- **Setting overrides**: Any project-specific configurations that override your + global user settings. + +### Security warnings and errors + +The trust dialog also highlights critical information that requires your +attention: + +- **Security Warnings**: The CLI will explicitly flag potentially dangerous + settings, such as auto-approving certain tools or disabling the security + sandbox. +- **Discovery Errors**: If the CLI encounters issues while scanning the folder + (for example, a malformed `settings.json` file), these errors will be + displayed prominently. + +By reviewing these details, you can ensure that you only grant trust to projects +that you know are safe. + +## Why trust matters: The impact of an untrusted workspace + +When a folder is **untrusted**, Gemini CLI runs in a restricted "safe mode" to +protect you. In this mode, the following features are disabled: + +1. **Workspace settings are ignored**: The CLI will **not** load the + `.gemini/settings.json` file from the project. This prevents the loading of + custom tools and other potentially dangerous configurations. + +2. **Environment variables are ignored**: The CLI will **not** load any `.env` + files from the project. + +3. **Extension management is restricted**: You **cannot install, update, or + uninstall** extensions. + +4. **Tool auto-acceptance is disabled**: You will always be prompted before any + tool is run, even if you have auto-acceptance enabled globally. + +5. **Automatic memory loading is disabled**: The CLI will not automatically + load files into context from directories specified in local settings. + +6. **MCP servers do not connect**: The CLI will not attempt to connect to any + [Model Context Protocol (MCP)](../tools/mcp-server.md) servers. + +7. **Custom commands are not loaded**: The CLI will not load any custom + commands from .toml files, including both project-specific and global user + commands. + +Granting trust to a folder unlocks the full functionality of Gemini CLI for that +workspace. + +## Headless and automated environments + +When running Gemini CLI in a headless environment (for example, a CI/CD +pipeline) where interactive prompts are not possible, the trust dialog cannot be +displayed. If the folder is untrusted and the Folder Trust feature is enabled, +the CLI will throw a `FatalUntrustedWorkspaceError` and exit. + +To proceed in these environments, you can bypass the trust check using one of +the following methods: + +- **Command-line flag:** Run the CLI with the `--skip-trust` flag. +- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true` + environment variable. + +These methods will trust the current workspace for the duration of the session +without prompting. + +For detailed instructions on managing folder trust within CI/CD workflows, +review the +[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md). + +## Overriding the trust file location + +By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you +need to store this file in a different location, you can set the +`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute +file path. + +## Managing your trust settings + +If you need to change a decision or see all your settings, you have a couple of +options: + +- **Change the current folder's trust**: Run the `/permissions` command from + within the CLI. This will bring up the same interactive dialog, allowing you + to change the trust level for the current folder. + +- **View all trust rules**: To see a complete list of all your trusted and + untrusted folder rules, you can inspect the contents of the + `~/.gemini/trustedFolders.json` file in your home directory. + +## The trust check process (advanced) + +For advanced users, it's helpful to know the exact order of operations for how +trust is determined: + +1. **IDE trust signal**: If you are using the + [IDE Integration](../ide-integration/index.md), the CLI first asks the IDE + if the workspace is trusted. The IDE's response takes highest priority. + +2. **Local trust file**: If the IDE is not connected, the CLI checks the + central `~/.gemini/trustedFolders.json` file. diff --git a/docs/cli/using-agent-skills.md b/docs/cli/using-agent-skills.md new file mode 100644 index 0000000000000000000000000000000000000000..63e64c02b713ee71f68d4b9511a49a6ea40b7756 --- /dev/null +++ b/docs/cli/using-agent-skills.md @@ -0,0 +1,89 @@ +# Managing Agent Skills + +Agent Skills provide Gemini CLI with specialized expertise on demand. This guide +covers advanced management techniques, including using slash commands, terminal +utilities, and understanding discovery tiers. + +## Discovery tiers + +Gemini CLI discovers skills from several locations, following a specific order +of precedence (lowest to highest): + +1. **Built-in Skills**: Included with Gemini CLI and always available. +2. **Extension Skills**: Bundled within [extensions](../extensions/index.md). +3. **User Skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/` + alias. These are available across all your projects. +4. **Workspace Skills**: Located in `.gemini/skills/` or the `.agents/skills/` + alias within your current directory. These are project-specific. + +> **Tip:** If multiple skills share the same name, the version from the +> higher-precedence location is used. + +## In-session management + +Use the `/skills` slash command during an interactive session to manage your +available expertise. + +- **List skills**: `/skills list` (shows discovered skills). + - Use `/skills list all` to include internal built-in skills. + - Use `/skills list nodesc` to hide descriptions. +- **Reload skills**: `/skills reload` (or `/skills refresh`) to scan for new or + modified skills without restarting the CLI. +- **Toggle status**: + - `/skills disable `: Prevents a skill from being triggered. + - `/skills enable `: Re-enables a disabled skill. +- **Link local skills**: `/skills link [--scope user|workspace]` to + immediately use a skill you are developing. + +## Terminal utilities + +The `gemini skills` command provides management utilities directly from your +system shell. + +### Install a skill + +To install a skill from a remote repository or a local `.skill` package: + +```bash +gemini skills install https://github.com/user/my-awesome-skill +``` + +By default, this installs to your **user profile**. Use `--scope workspace` to +install it only for the current project. + +### Link for development + +If you are developing a skill, use the `link` command to create a reference to +your local directory: + +```bash +gemini skills link ./path/to/my-skill +``` + +### Uninstall a skill + +To completely remove an installed or linked skill: + +```bash +gemini skills uninstall +``` + +## Security and consent + +Agent Skills can execute scripts and access your files. To protect your +environment: + +1. **Installation consent**: When installing from a remote URL, you will be + asked to confirm the source. +2. **Activation consent**: Every time a skill is triggered during a session, + the agent must ask for permission to activate it and gain access to its + resources. + +## Next steps + +- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A + walkthrough for creating your first skill. +- [Creating Agent Skills](./creating-skills.md): Detailed guide on bundling + scripts and assets. +- [Skill best practices](./skills-best-practices.md): Strategies for building + reliable expertise. diff --git a/docs/core/gemma-setup.md b/docs/core/gemma-setup.md new file mode 100644 index 0000000000000000000000000000000000000000..166bdc885bd93eb8e1b01c64846f3bd7a81e57d9 --- /dev/null +++ b/docs/core/gemma-setup.md @@ -0,0 +1,83 @@ +# `gemini gemma` β€” Automated Local Model Routing Setup + +Local model routing uses a local Gemma 3 1B model running on your machine to +classify and route user requests. It routes simple requests (like file reads) to +Gemini Flash and complex requests (like architecture discussions) to Gemini Pro. + + +> [!NOTE] +> This is an experimental feature currently under active development. + +## What is this? + +This feature saves cloud API costs by using local inference for task +classification instead of a cloud-based classifier. It adds a few milliseconds +of local latency but can significantly reduce the overall token usage for hosted +models. + +## Quick start + +```bash +# One command does everything: downloads runtime, pulls model, configures settings, starts server +gemini gemma setup +``` + +You'll be prompted to accept the Gemma Terms of Use. The model is ~1 GB. + +After setup, **just use the CLI normally** β€” routing happens automatically on +every request. + +## Commands + +| Command | What it does | +| --------------------- | -------------------------------------------------------------- | +| `gemini gemma setup` | Full install (binary + model + settings + server start) | +| `gemini gemma status` | Health check β€” shows what's installed and running | +| `gemini gemma start` | Start the LiteRT server (auto-starts on CLI launch by default) | +| `gemini gemma stop` | Stop the LiteRT server | +| `gemini gemma logs` | Tail the server logs to see routing requests live | +| `/gemma` | In-session status check (type it inside the CLI) | + +## Verifying it works + +1. Run `gemini gemma status` β€” all checks should show green +2. Open two terminals: + - Terminal 1: `gemini gemma logs` (watch for incoming requests) + - Terminal 2: use the CLI normally +3. You should see classification requests appear in the logs as you interact + with the CLI +4. The `/gemma` slash command inside a session shows a quick status panel + +## Setup flags + +```bash +gemini gemma setup --port 8080 # custom port +gemini gemma setup --no-start # don't start server after install +gemini gemma setup --force # re-download everything +gemini gemma setup --skip-model # binary only, skip the 1GB model download +``` + +## How it works under the hood + +- Local Gemma classifies each request as "simple" or "complex" (~100ms) +- Simple β†’ Flash, Complex β†’ Pro +- If the local server is down, the CLI silently falls back to the cloud + classifier β€” no errors, no disruption + +## Disabling + +Set `enabled: false` in settings or just run `gemini gemma stop` to turn off the +server: + +```json +{ "experimental": { "gemmaModelRouter": { "enabled": false } } } +``` + +## Advanced setup + +If you are in an environment where the `gemini gemma setup` command cannot +automatically download binaries (for example, behind a strict corporate +firewall), you can perform the setup manually. + +For more information, see the +[Manual Local Model Routing Setup guide](./local-model-routing.md). diff --git a/docs/core/index.md b/docs/core/index.md new file mode 100644 index 0000000000000000000000000000000000000000..ca10cc6e4856bb064360767d1d4cf24ad242fdd9 --- /dev/null +++ b/docs/core/index.md @@ -0,0 +1,109 @@ +# Gemini CLI core + +Gemini CLI's core package (`packages/core`) is the backend portion of Gemini +CLI, handling communication with the Gemini API, managing tools, and processing +requests sent from `packages/cli`. For a general overview of Gemini CLI, see the +[main documentation page](../index.md). + +## Navigating this section + +- **[Sub-agents](./subagents.md):** Learn how to create and use specialized + sub-agents for complex tasks. +- **[Core tools reference](../reference/tools.md):** Information on how tools + are defined, registered, and used by the core. +- **[Memory Import Processor](../reference/memport.md):** Documentation for the + modular GEMINI.md import feature using @file.md syntax. +- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for + fine-grained control over tool execution. +- **[Local Model Routing (experimental)](./gemma-setup.md):** Learn how to + enable use of a local Gemma model for model routing decisions using the + automated setup command. + +## Role of the core + +While the `packages/cli` portion of Gemini CLI provides the user interface, +`packages/core` is responsible for: + +- **Gemini API interaction:** Securely communicating with the Google Gemini API, + sending user prompts, and receiving model responses. +- **Prompt engineering:** Constructing effective prompts for the Gemini model, + potentially incorporating conversation history, tool definitions, and + instructional context from `GEMINI.md` files. +- **Tool management & orchestration:** + - Registering available tools (for example, file system tools, shell command + execution). + - Interpreting tool use requests from the Gemini model. + - Executing the requested tools with the provided arguments. + - Returning tool execution results to the Gemini model for further processing. +- **Session and state management:** Keeping track of the conversation state, + including history and any relevant context required for coherent interactions. +- **Configuration:** Managing core-specific configurations, such as API key + access, model selection, and tool settings. + +## Security considerations + +The core plays a vital role in security: + +- **API key management:** It handles the `GEMINI_API_KEY` and ensures it's used + securely when communicating with the Gemini API. +- **Tool execution:** When tools interact with the local system (for example, + `run_shell_command`), the core (and its underlying tool implementations) must + do so with appropriate caution, often involving sandboxing mechanisms to + prevent unintended modifications. + +## Chat history compression + +To ensure that long conversations don't exceed the token limits of the Gemini +model, the core includes a chat history compression feature. + +When a conversation approaches the token limit for the configured model, the +core automatically compresses the conversation history before sending it to the +model. This compression is designed to be lossless in terms of the information +conveyed, but it reduces the overall number of tokens used. + +You can find the token limits for each model in the +[Google AI documentation](https://ai.google.dev/gemini-api/docs/models). + +## Model fallback + +Gemini CLI includes a model fallback mechanism to ensure that you can continue +to use the CLI even if the default "pro" model is rate-limited. + +If you are using the default "pro" model and the CLI detects that you are being +rate-limited, it automatically switches to the "flash" model for the current +session. This lets you continue working without interruption. + +Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt +completion and classification) silently fall back to `gemini-2.5-flash` and +`gemini-2.5-pro` when quota is exhausted, without changing the configured model. + +## File discovery service + +The file discovery service is responsible for finding files in the project that +are relevant to the current context. It is used by the `@` command and other +tools that need to access files. + +## Memory discovery service + +The memory discovery service is responsible for finding and loading the +`GEMINI.md` files that provide context to the model. It searches for these files +in a hierarchical manner, starting from the current working directory and moving +up to the project root and the user's home directory. It also searches in +subdirectories. + +This lets you have global, project-level, and component-level context files, +which are all combined to provide the model with the most relevant information. + +You can use the [`/memory` command](../reference/commands.md) to `show`, `add`, +and `refresh` the content of loaded `GEMINI.md` files. + +## Citations + +When Gemini finds it is reciting text from a source it appends the citation to +the output. It is enabled by default but can be disabled with the +ui.showCitations setting. + +- When proposing an edit the citations display before giving the user the option + to accept. +- Citations are always shown at the end of the model’s turn. +- We deduplicate citations and display them in alphabetical order. diff --git a/docs/core/local-model-routing.md b/docs/core/local-model-routing.md new file mode 100644 index 0000000000000000000000000000000000000000..3ab3709ed1dcc6330a81f5d3fdcd2eeb9e3bd910 --- /dev/null +++ b/docs/core/local-model-routing.md @@ -0,0 +1,200 @@ +# Manual Local Model Routing Setup (experimental) + +Gemini CLI supports using a local model for +[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will +use a locally-running **Gemma** model to make routing decisions (instead of +sending routing decisions to a hosted model). + + +> [!NOTE] +> This is an experimental feature currently under active development. + + +> [!IMPORTANT] +> **Recommended:** We now provide a fully automated setup command. We recommend +> using the [`gemini gemma` Setup Guide](./gemma-setup.md) instead of following +> these manual steps. + +This feature can help reduce costs associated with hosted model usage while +offering similar routing decision latency and quality. + +## Manual Setup + +Using a Gemma model for routing decisions requires that an implementation of a +Gemma model be running locally on your machine, served behind an HTTP endpoint +and accessed via the Gemini API. If you cannot use the `gemini gemma setup` +command, follow these manual steps: + +### Download the LiteRT-LM runtime + +The [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime offers +pre-built binaries for locally-serving models. Download the binary appropriate +for your system. + +#### Windows + +1. Download + [lit.windows_x86_64.exe](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.windows_x86_64.exe). +2. Using GPU on Windows requires the DirectXShaderCompiler. Download the + [dxc zip from the latest release](https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip). + Unzip the archive and from the architecture-appropriate `bin\` directory, and + copy the `dxil.dll` and `dxcompiler.dll` into the same location as you saved + `lit.windows_x86_64.exe`. +3. (Optional) Test starting the runtime: + `.\lit.windows_x86_64.exe serve --verbose` + +#### Linux + +1. Download + [lit.linux_x86_64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.linux_x86_64). +2. Ensure the binary is executable: `chmod a+x lit.linux_x86_64` +3. (Optional) Test starting the runtime: `./lit.linux_x86_64 serve --verbose` + +#### MacOS + +1. Download + [lit-macos-arm64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.macos_arm64). +2. Ensure the binary is executable: `chmod a+x lit.macos_arm64` +3. (Optional) Test starting the runtime: `./lit.macos_arm64 serve --verbose` + +> **Note**: MacOS can be configured to only allows binaries from "App Store & +> Known Developers". If you encounter an error message when attempting to run +> the binary, you will need to allow the application. One option is to visit +> `System Settings -> Privacy & Security`, scroll to `Security`, and click +> `"Allow Anyway"` for `"lit.macos_arm64"`. Another option is to run +> `xattr -d com.apple.quarantine lit.macos_arm64` from the commandline. + +### Download the Gemma Model + +Before using Gemma, you will need to download the model (and agree to the Terms +of Service). + +This can be done via the LiteRT-LM runtime. + +#### Windows + +```bash +$ .\lit.windows_x86_64.exe pull gemma3-1b-gpu-custom + +[Legal] The model you are about to download is governed by +the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. + +Full Terms: https://ai.google.dev/gemma/terms +Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy + +Do you accept these terms? (Y/N): Y + +Terms accepted. +Downloading model 'gemma3-1b-gpu-custom' ... +Downloading... 968.6 MB +Download complete. +``` + +#### Linux + +```bash +$ ./lit.linux_x86_64 pull gemma3-1b-gpu-custom + +[Legal] The model you are about to download is governed by +the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. + +Full Terms: https://ai.google.dev/gemma/terms +Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy + +Do you accept these terms? (Y/N): Y + +Terms accepted. +Downloading model 'gemma3-1b-gpu-custom' ... +Downloading... 968.6 MB +Download complete. +``` + +#### MacOS + +```bash +$ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom + +[Legal] The model you are about to download is governed by +the Gemma Terms of Use and Prohibited Use Policy. Review these terms and ensure you agree before continuing. + +Full Terms: https://ai.google.dev/gemma/terms +Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy + +Do you accept these terms? (Y/N): Y + +Terms accepted. +Downloading model 'gemma3-1b-gpu-custom' ... +Downloading... 968.6 MB +Download complete. +``` + +### Start LiteRT-LM Runtime + +Using the command appropriate to your system, start the LiteRT-LM runtime. +Configure the port that you want to use for your Gemma model. For the purposes +of this document, we will use port `9379`. + +Example command for MacOS: `./lit.macos_arm64 serve --port=9379 --verbose` + +### (Optional) Verify Model Serving + +Send a quick prompt to the model via HTTP to validate successful model serving. +This will cause the runtime to download the model and run it once. + +You should see a short joke in the server output as an indicator of success. + +#### Windows + +``` +# Run this in PowerShell to send a request to the server + +$uri = "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" +$body = @{contents = @( @{ + role = "user" + parts = @( @{ text = "Tell me a joke." } ) +})} | ConvertTo-Json -Depth 10 + +Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json" +``` + +#### Linux/MacOS + +```bash +$ curl "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d '{"contents":[{"role":"user","parts":[{"text":"Tell me a joke."}]}]}' +``` + +## Configuration + +To use a local Gemma model for routing, you must explicitly enable it in your +`settings.json`: + +```json +{ + "experimental": { + "gemmaModelRouter": { + "enabled": true, + "classifier": { + "host": "http://localhost:9379", + "model": "gemma3-1b-gpu-custom" + } + } + } +} +``` + +> Use the port you started your LiteRT-LM runtime on in the setup steps. + +### Configuration schema + +| Field | Type | Required | Description | +| :----------------- | :------ | :------- | :----------------------------------------------------------------------------------------- | +| `enabled` | boolean | Yes | Must be `true` to enable the feature. | +| `classifier` | object | Yes | The configuration for the local model endpoint. It includes the host and model specifiers. | +| `classifier.host` | string | Yes | The URL to the local model server. Should be `http://localhost:`. | +| `classifier.model` | string | Yes | The model name to use for decisions. Must be `"gemma3-1b-gpu-custom"`. | + +> **Note: You will need to restart after configuration changes for local model +> routing to take effect.** diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md new file mode 100644 index 0000000000000000000000000000000000000000..7a3e7ffe2aa035934f45532e40525bdd38849a93 --- /dev/null +++ b/docs/core/remote-agents.md @@ -0,0 +1,457 @@ +# Remote Subagents + +Gemini CLI supports connecting to remote subagents using the Agent-to-Agent +(A2A) protocol. This allows Gemini CLI to interact with other agents, expanding +its capabilities by delegating tasks to remote services. + +Gemini CLI can connect to any compliant A2A agent. You can find samples of A2A +agents in the following repositories: + +- [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) +- [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) + +## Proxy support + +Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is +configured. It uses the `general.proxy` setting in your `settings.json` file or +standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`). + +```json +{ + "general": { + "proxy": "http://my-proxy:8080" + } +} +``` + +## Defining remote subagents + +Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter. +You can place them in: + +1. **Project-level:** `.gemini/agents/*.md` (Shared with your team) +2. **User-level:** `~/.gemini/agents/*.md` (Personal agents) + +### Configuration schema + +| Field | Type | Required | Description | +| :---------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- | +| `kind` | string | Yes | Must be `remote`. | +| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). | +| `agent_card_url` | string | Yes\* | The URL to the agent's A2A card endpoint. Required if `agent_card_json` is not provided. | +| `agent_card_json` | string | Yes\* | The inline JSON string of the agent's A2A card. Required if `agent_card_url` is not provided. | +| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). | + +### Single-subagent example + +```markdown +--- +kind: remote +name: my-remote-agent +agent_card_url: https://example.com/agent-card +--- +``` + +### Multi-subagent example + +The loader explicitly supports multiple remote subagents defined in a single +Markdown file. + +```markdown +--- +- kind: remote + name: remote-1 + agent_card_url: https://example.com/1 +- kind: remote + name: remote-2 + agent_card_url: https://example.com/2 +--- +``` + + +> [!NOTE] Mixed local and remote agents, or multiple local agents, are not +> supported in a single file; the list format is currently remote-only. + +### Inline Agent Card JSON + +
+View formatting options for JSON strings + +If you don't have an endpoint serving the agent card, you can provide the A2A +card directly as a JSON string using `agent_card_json`. + +When providing a JSON string in YAML, you must properly format it as a string +scalar. You can use single quotes, a block scalar, or double quotes (which +require escaping internal double quotes). + +#### Using single quotes + +Single quotes allow you to embed unescaped double quotes inside the JSON string. +This format is useful for shorter, single-line JSON strings. + +```markdown +--- +kind: remote +name: single-quotes-agent +agent_card_json: + '{ "protocolVersion": "0.3.0", "name": "Example Agent", "version": "1.0.0", + "url": "dummy-url" }' +--- +``` + +#### Using a block scalar + +The literal block scalar (`|`) preserves line breaks and is highly recommended +for multiline JSON strings as it avoids quote escaping entirely. The following +is a complete, valid Agent Card configuration using dummy values. + +```markdown +--- +kind: remote +name: block-scalar-agent +agent_card_json: | + { + "protocolVersion": "0.3.0", + "name": "Example Agent Name", + "description": "An example agent description for documentation purposes.", + "version": "1.0.0", + "url": "dummy-url", + "preferredTransport": "HTTP+JSON", + "capabilities": { + "streaming": true, + "extendedAgentCard": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "application/json" + ], + "skills": [ + { + "id": "ExampleSkill", + "name": "Example Skill Assistant", + "description": "A description of what this example skill does.", + "tags": [ + "example-tag" + ], + "examples": [ + "Show me an example." + ] + } + ] + } +--- +``` + +#### Using double quotes + +Double quotes are also supported, but any internal double quotes in your JSON +must be escaped with a backslash. + +```markdown +--- +kind: remote +name: double-quotes-agent +agent_card_json: + '{ "protocolVersion": "0.3.0", "name": "Example Agent", "version": "1.0.0", + "url": "dummy-url" }' +--- +``` + +
+ +## Authentication + +Many remote agents require authentication. Gemini CLI supports several +authentication methods aligned with the +[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme). +Add an `auth` block to your agent's frontmatter to configure credentials. + +### Supported auth types + +Gemini CLI supports the following authentication types: + +| Type | Description | +| :------------------- | :--------------------------------------------------------------------------------------------- | +| `apiKey` | Send a static API key as an HTTP header. | +| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). | +| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. | +| `oauth` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. | + +### Dynamic values + +For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`, +`password`, `value`) support dynamic resolution: + +| Format | Description | Example | +| :---------- | :-------------------------------------------------- | :------------------------- | +| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` | +| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` | +| literal | Use the string as-is. | `sk-abc123` | +| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` | + +> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets +> directly in agent files, especially for project-level agents checked into +> version control. + +### API key (`apiKey`) + +Sends an API key as an HTTP header on every request. + +| Field | Type | Required | Description | +| :----- | :----- | :------- | :---------------------------------------------------- | +| `type` | string | Yes | Must be `apiKey`. | +| `key` | string | Yes | The API key value. Supports dynamic values. | +| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. | + +```yaml +--- +kind: remote +name: my-agent +agent_card_url: https://example.com/agent-card +auth: + type: apiKey + key: $MY_API_KEY +--- +``` + +### HTTP authentication (`http`) + +Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP +authentication schemes. + +#### Bearer token + +Use the following fields to configure a Bearer token: + +| Field | Type | Required | Description | +| :------- | :----- | :------- | :----------------------------------------- | +| `type` | string | Yes | Must be `http`. | +| `scheme` | string | Yes | Must be `Bearer`. | +| `token` | string | Yes | The bearer token. Supports dynamic values. | + +```yaml +auth: + type: http + scheme: Bearer + token: $MY_BEARER_TOKEN +``` + +#### Basic authentication + +Use the following fields to configure Basic authentication: + +| Field | Type | Required | Description | +| :--------- | :----- | :------- | :------------------------------------- | +| `type` | string | Yes | Must be `http`. | +| `scheme` | string | Yes | Must be `Basic`. | +| `username` | string | Yes | The username. Supports dynamic values. | +| `password` | string | Yes | The password. Supports dynamic values. | + +```yaml +auth: + type: http + scheme: Basic + username: $MY_USERNAME + password: $MY_PASSWORD +``` + +#### Raw scheme + +For any other IANA-registered scheme (for example, Digest, HOBA), provide the +raw authorization value. + +| Field | Type | Required | Description | +| :------- | :----- | :------- | :---------------------------------------------------------------------------- | +| `type` | string | Yes | Must be `http`. | +| `scheme` | string | Yes | The scheme name (for example, `Digest`). | +| `value` | string | Yes | Raw value sent as `Authorization: `. Supports dynamic values. | + +```yaml +auth: + type: http + scheme: Digest + value: $MY_DIGEST_VALUE +``` + +### Google Application Default Credentials (`google-credentials`) + +Uses +[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials) +to authenticate with Google Cloud services and Cloud Run endpoints. This is the +recommended auth method for agents hosted on Google Cloud infrastructure. + +| Field | Type | Required | Description | +| :------- | :------- | :------- | :-------------------------------------------------------------------------- | +| `type` | string | Yes | Must be `google-credentials`. | +| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. | + +```yaml +--- +kind: remote +name: my-gcp-agent +agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json +auth: + type: google-credentials +--- +``` + +#### How token selection works + +The provider automatically selects the correct token type based on the agent's +host: + +| Host pattern | Token type | Use case | +| :----------------- | :----------------- | :------------------------------------------ | +| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) | +| `*.run.app` | **Identity token** | Cloud Run services | + +- **Access tokens** authorize API calls to Google services. They are scoped + (default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`. +- **Identity tokens** prove the caller's identity to a service that validates + the token's audience. The audience is set to the target host. These are + fetched via `GoogleAuth.getIdTokenClient()`. + +Both token types are cached and automatically refreshed before expiry. + +#### Setup + +`google-credentials` relies on ADC, which means your environment must have +credentials configured. Common setups: + +- **Local development:** Run `gcloud auth application-default login` to + authenticate with your Google account. +- **CI / Cloud environments:** Use a service account. Set the + `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your + service account key file, or use workload identity on GKE / Cloud Run. + +#### Allowed hosts + +For security, `google-credentials` only sends tokens to known Google-owned +hosts: + +- `*.googleapis.com` +- `*.run.app` + +Requests to any other host will be rejected with an error. If your agent is +hosted on a different domain, use one of the other auth types (`apiKey`, `http`, +or `oauth`). + +#### Examples + +The following examples demonstrate how to configure Google Application Default +Credentials. + +**Cloud Run agent:** + +```yaml +--- +kind: remote +name: cloud-run-agent +agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json +auth: + type: google-credentials +--- +``` + +**Google API with custom scopes:** + +```yaml +--- +kind: remote +name: vertex-agent +agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json +auth: + type: google-credentials + scopes: + - https://www.googleapis.com/auth/cloud-platform + - https://www.googleapis.com/auth/compute +--- +``` + +### OAuth 2.0 (`oauth`) + +Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first +use, Gemini CLI opens your browser for sign-in and persists the resulting tokens +for subsequent requests. + +| Field | Type | Required | Description | +| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | string | Yes | Must be `oauth`. | +| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. | +| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. | +| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. | +| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. | +| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. | + +```yaml +--- +kind: remote +name: oauth-agent +agent_card_url: https://example.com/.well-known/agent.json +auth: + type: oauth + client_id: my-client-id.apps.example.com +--- +``` + +If the agent card advertises an `oauth2` security scheme with +`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are +automatically discovered. You only need to provide `client_id` (and +`client_secret` if required). + +Tokens are persisted to disk and refreshed automatically when they expire. + +### Auth validation + +When Gemini CLI loads a remote agent, it validates your auth configuration +against the agent card's declared `securitySchemes`. If the agent requires +authentication that you haven't configured, you'll see an error describing +what's needed. + +`google-credentials` is treated as compatible with `http` Bearer security +schemes, since it produces Bearer tokens. + +### Auth retry behavior + +All auth providers automatically retry on `401` and `403` responses by +re-fetching credentials (up to 2 retries). This handles cases like expired +tokens or rotated credentials. For `apiKey` with `!command` values, the command +is re-executed on retry to fetch a fresh key. + +### Agent card fetching and auth + +When connecting to a remote agent, Gemini CLI first fetches the agent card +**without** authentication. If the card endpoint returns a `401` or `403`, it +retries the fetch **with** the configured auth headers. This lets agents have +publicly accessible cards while protecting their task endpoints, or to protect +both behind auth. + +## Managing Subagents + +Users can manage subagents using the following commands within Gemini CLI: + +- `/agents list`: Displays all available local and remote subagents. +- `/agents reload`: Reloads the agent registry. Use this after adding or + modifying agent definition files. +- `/agents enable `: Enables a specific subagent. +- `/agents disable `: Disables a specific subagent. + + +> [!TIP] +> You can use the `@cli_help` agent within Gemini CLI for assistance +> with configuring subagents. + +## Disabling remote agents + +Remote subagents are enabled by default. To disable them, set `enableAgents` to +`false` in your `settings.json`: + +```json +{ + "experimental": { + "enableAgents": false + } +} +``` diff --git a/docs/core/subagents.md b/docs/core/subagents.md new file mode 100644 index 0000000000000000000000000000000000000000..e8dbc4251442995965c8fe1b5364ea295023e57c --- /dev/null +++ b/docs/core/subagents.md @@ -0,0 +1,598 @@ +# Subagents + +Subagents are specialized agents that operate within your main Gemini CLI +session. They are designed to handle specific, complex tasksβ€”like deep codebase +analysis, documentation lookup, or domain-specific reasoningβ€”without cluttering +the main agent's context or toolset. + +## What are subagents? + +Subagents are "specialists" that the main Gemini agent can hire for a specific +job. + +- **Focused context:** Each subagent has its own system prompt and persona. +- **Specialized tools:** Subagents can have a restricted or specialized set of + tools. +- **Independent context window:** Interactions with a subagent happen in a + separate context loop, which saves tokens in your main conversation history. + +Subagents are exposed to the main agent as a tool of the same name. When the +main agent calls the tool, it delegates the task to the subagent. Once the +subagent completes its task, it reports back to the main agent with its +findings. + +## How to use subagents + +You can use subagents through automatic delegation or by explicitly forcing them +in your prompt. + +### Automatic delegation + +Gemini CLI's main agent is instructed to use specialized subagents when a task +matches their expertise. For example, if you ask "How does the auth system +work?", the main agent may decide to call the `codebase_investigator` subagent +to perform the research. + +### Forcing a subagent (@ syntax) + +You can explicitly direct a task to a specific subagent by using the `@` symbol +followed by the subagent's name at the beginning of your prompt. This is useful +when you want to bypass the main agent's decision-making and go straight to a +specialist. + +**Example:** + +```bash +@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor. +``` + +When you use the `@` syntax, the CLI injects a system note that nudges the +primary model to use that specific subagent tool immediately. + +## Built-in subagents + +Gemini CLI comes with the following built-in subagents: + +### Codebase Investigator + +- **Name:** `codebase_investigator` +- **Purpose:** Analyze the codebase, reverse engineer, and understand complex + dependencies. +- **When to use:** "How does the authentication system work?", "Map out the + dependencies of the `AgentRegistry` class." +- **Configuration:** Enabled by default. You can override its settings in + `settings.json` under `agents.overrides`. Example (forcing a specific model + and increasing turns): + ```json + { + "agents": { + "overrides": { + "codebase_investigator": { + "modelConfig": { "model": "gemini-3-flash-preview" }, + "runConfig": { "maxTurns": 50 } + } + } + } + } + ``` + +### CLI Help Agent + +- **Name:** `cli_help` +- **Purpose:** Get expert knowledge about Gemini CLI itself, its commands, + configuration, and documentation. +- **When to use:** "How do I configure a proxy?", "What does the `/rewind` + command do?" +- **Configuration:** Enabled by default. + +### Generalist Agent + +- **Name:** `generalist` +- **Purpose:** A general, all-purpose subagent that uses the inherited tool + access and configurations from the main agent. Useful for executing broad, + resource-heavy subtasks in an isolated conversation, optimizing your main + agent's context by returning only the final result of that given task. +- **When to use:** Use this agent when a task requires many steps, handles large + volumes of information, or requires the same full capabilities as the main + agent. It is ideal for: + - **Multi-file modifications:** Applying refactors or fixing errors across + several files at once. + - **High-volume execution:** Running commands or tests that produce extensive + terminal output. + - **Action-oriented research:** Investigations where the agent needs to both + search code and run commands or make edits to find a solution. By delegating + these tasks, you prevent your main conversation from becoming cluttered or + slow. You can invoke it explicitly using `@generalist`. +- **Configuration:** Enabled by default. + +### Browser Agent + +- **Name:** `browser_agent` +- **Purpose:** Automate web browser tasks β€” navigating websites, filling forms, + clicking buttons, and extracting information from web pages β€” using the + accessibility tree. +- **When to use:** "Go to example.com and fill out the contact form," "Extract + the pricing table from this page," "Click the login button and enter my + credentials." + +#### Prerequisites + +The browser agent requires: + +- **Chrome** version 144 or later (any recent stable release works). + +The underlying +[`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp) +server is bundled with Gemini CLI and launched automatically β€” no separate +installation is needed. + +#### Enabling the browser agent + +The browser agent is disabled by default. Enable it in your `settings.json`: + +```json +{ + "agents": { + "overrides": { + "browser_agent": { + "enabled": true + } + } + } +} +``` + +#### Session modes + +The `sessionMode` setting controls how Chrome is launched and managed. Set it +under `agents.browser`: + +```json +{ + "agents": { + "overrides": { + "browser_agent": { + "enabled": true + } + }, + "browser": { + "sessionMode": "persistent" + } + } +} +``` + +The available modes are: + +| Mode | Description | +| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. | +| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. | +| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. | + +#### First-run consent + +The first time the browser agent is invoked, Gemini CLI displays a consent +dialog. You must accept before the browser session starts. This dialog only +appears once. + +#### Configuration reference + +All browser-specific settings go under `agents.browser` in your `settings.json`. +For full details, see the +[`agents.browser` configuration reference](../reference/configuration.md#agents). + +| Setting | Type | Default | Description | +| :------------------------ | :--------- | :------------- | :------------------------------------------------------------------------------ | +| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. | +| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). | +| `profilePath` | `string` | β€” | Custom path to a browser profile directory. | +| `visualModel` | `string` | β€” | Model override for the visual agent. | +| `allowedDomains` | `string[]` | β€” | Restrict navigation to specific domains (for example, `["github.com"]`). | +| `disableUserInput` | `boolean` | `true` | Disable user input on the browser window during automation (non-headless only). | +| `maxActionsPerTask` | `number` | `100` | Maximum tool calls per task. The agent is terminated when the limit is reached. | +| `confirmSensitiveActions` | `boolean` | `false` | Require manual confirmation for `upload_file` and `evaluate_script`. | +| `blockFileUploads` | `boolean` | `false` | Hard-block all file upload requests from the agent. | + +#### Automation overlay and input blocking + +In non-headless mode, the browser agent injects a visual overlay into the +browser window to indicate that automation is in progress. By default, user +input (keyboard and mouse) is also blocked to prevent accidental interference. +You can disable this by setting `disableUserInput` to `false`. + +#### Security + +The browser agent enforces several layers of security: + +- **Domain restrictions:** When `allowedDomains` is set, the agent can only + navigate to the listed domains (and their subdomains when using `*.` prefix). + Attempting to visit a disallowed domain throws a fatal error that immediately + terminates the agent. The agent also attempts to detect and block the use of + allowed domains as proxies (e.g., via query parameters or fragments) to access + restricted content. +- **Blocked URL patterns:** The underlying MCP server blocks dangerous URL + schemes including `file://`, `javascript:`, `data:text/html`, + `chrome://extensions`, and `chrome://settings/passwords`. +- **Sensitive action confirmation:** Form filling (`fill`, `fill_form`) always + requires user confirmation through the policy engine, regardless of approval + mode. When `confirmSensitiveActions` is `true`, `upload_file` and + `evaluate_script` also require confirmation. +- **File upload blocking:** Set `blockFileUploads` to `true` to hard-block all + file upload requests, preventing the agent from uploading any files. +- **Action rate limiting:** The `maxActionsPerTask` setting (default: 100) + limits the total number of tool calls per task to prevent runaway execution. + +#### Visual agent + +By default, the browser agent interacts with pages through the accessibility +tree using element `uid` values. For tasks that require visual identification +(for example, "click the yellow button" or "find the red error message"), you +can enable the visual agent by setting a `visualModel`: + +```json +{ + "agents": { + "overrides": { + "browser_agent": { + "enabled": true + } + }, + "browser": { + "visualModel": "gemini-2.5-computer-use-preview-10-2025" + } + } +} +``` + +When enabled, the agent gains access to the `analyze_screenshot` tool, which +captures a screenshot and sends it to the vision model for analysis. The model +returns coordinates and element descriptions that the browser agent uses with +the `click_at` tool for precise, coordinate-based interactions. + + +> [!NOTE] +> The visual agent requires API key or Vertex AI authentication. It is +> not available when using "Sign in with Google". + +#### Sandbox support + +The browser agent adjusts its behavior automatically when running inside a +sandbox. + +##### macOS seatbelt (`sandbox-exec`) + +When the CLI runs under the macOS seatbelt sandbox, `persistent` and `isolated` +session modes are forced to `isolated` with `headless` enabled. This avoids +permission errors caused by seatbelt file-system restrictions on persistent +browser profiles. If `sessionMode` is set to `existing`, no override is applied. + +##### Container sandboxes (Docker / Podman) + +Chrome is not available inside the container, so the browser agent is +**disabled** unless `sessionMode` is set to `"existing"`. When enabled with +`existing` mode, the agent automatically connects to Chrome on the host via the +resolved IP of `host.docker.internal:9222` instead of using local pipe +discovery. Port `9222` is currently hardcoded and cannot be customized. + +To use the browser agent in a Docker sandbox: + +1. Start Chrome on the host with remote debugging enabled: + + ```bash + # Option A: Launch Chrome from the command line + google-chrome --remote-debugging-port=9222 + + # Option B: Enable in Chrome settings + # Navigate to chrome://inspect/#remote-debugging and enable + ``` + +2. Configure `sessionMode` and allowed domains in your project's + `.gemini/settings.json`: + + ```json + { + "agents": { + "overrides": { + "browser_agent": { "enabled": true } + }, + "browser": { + "sessionMode": "existing", + "allowedDomains": ["example.com"] + } + } + } + ``` + +3. Launch the CLI with port forwarding: + + ```bash + GEMINI_SANDBOX=docker SANDBOX_PORTS=9222 gemini + ``` + +## Creating custom subagents + +You can create your own subagents to automate specific workflows or enforce +specific personas. + +### Agent definition files + +Custom agents are defined as Markdown files (`.md`) with YAML frontmatter. You +can place them in: + +1. **Project-level:** `.gemini/agents/*.md` (Shared with your team) +2. **User-level:** `~/.gemini/agents/*.md` (Personal agents) + +### File format + +The file **MUST** start with YAML frontmatter enclosed in triple-dashes `---`. +The body of the markdown file becomes the agent's **System Prompt**. + +**Example: `.gemini/agents/security-auditor.md`** + +```markdown +--- +name: security-auditor +description: Specialized in finding security vulnerabilities in code. +kind: local +tools: + - read_file + - grep_search +model: gemini-3-flash-preview +temperature: 0.2 +max_turns: 10 +--- + +You are a ruthless Security Auditor. Your job is to analyze code for potential +vulnerabilities. + +Focus on: + +1. SQL Injection +2. XSS (Cross-Site Scripting) +3. Hardcoded credentials +4. Unsafe file operations + +When you find a vulnerability, explain it clearly and suggest a fix. Do not fix +it yourself; just report it. +``` + +### Configuration schema + +| Field | Type | Required | Description | +| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. | +| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. | +| `kind` | string | No | `local` (default) or `remote`. | +| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** | +| `mcpServers` | object | No | Configuration for inline Model Context Protocol (MCP) servers isolated to this specific agent. | +| `model` | string | No | Specific model to use (for example, `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | +| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. | +| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. | +| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. | + +### Tool wildcards + +When defining `tools` for a subagent, you can use wildcards to quickly grant +access to groups of tools: + +- `*`: Grant access to all available built-in and discovered tools. +- `mcp_*`: Grant access to all tools from all connected MCP servers. +- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named + `my-server`. + +### Isolation and recursion protection + +Each subagent runs in its own isolated context loop. This means: + +- **Independent history:** The subagent's conversation history does not bloat + the main agent's context. +- **Isolated tools:** The subagent only has access to the tools you explicitly + grant it. +- **Recursion protection:** To prevent infinite loops and excessive token usage, + subagents **cannot** call other subagents. If a subagent is granted the `*` + tool wildcard, it will still be unable to see or invoke other agents. + +## Subagent tool isolation + +Subagent tool isolation moves Gemini CLI away from a single global tool +registry. By providing isolated execution environments, you can ensure that +subagents only interact with the parts of the system they are designed for. This +prevents unintended side effects, improves reliability by avoiding state +contamination, and enables fine-grained permission control. + +With this feature, you can: + +- **Specify tool access:** Define exactly which tools an agent can access using + a `tools` list in the agent definition. +- **Define inline MCP servers:** Configure Model Context Protocol (MCP) servers + (which provide a standardized way to connect AI models to external tools and + data sources) directly in the subagent's markdown frontmatter, isolating them + to that specific agent. +- **Maintain state isolation:** Ensure that subagents only interact with their + own set of tools and servers, preventing side effects and state contamination. +- **Apply subagent-specific policies:** Enforce granular rules in your + [Policy Engine](../reference/policy-engine.md) TOML configuration based on the + executing subagent's name. + +### Configuring isolated tools and servers + +You can configure tool isolation for a subagent by updating its markdown +frontmatter. This lets you explicitly state which tools the subagent can use, +rather than relying on the global registry. + +Add an `mcpServers` object to define inline MCP servers that are unique to the +agent. + +**Example:** + +```yaml +--- +name: my-isolated-agent +tools: + - grep_search + - read_file +mcpServers: + my-custom-server: + command: 'node' + args: ['path/to/server.js'] +--- +``` + +### Subagent-specific policies + +You can enforce fine-grained control over subagents using the +[Policy Engine's](../reference/policy-engine.md) TOML configuration. This allows +you to grant or restrict permissions specifically for an agent, without +affecting the rest of your CLI session. + +To restrict a policy rule to a specific subagent, add the `subagent` property to +the `[[rules]]` block in your `policy.toml` file. + +**Example:** + +```toml +[[rules]] +name = "Allow pr-creator to push code" +subagent = "pr-creator" +description = "Permit pr-creator to push branches automatically." +action = "allow" +toolName = "run_shell_command" +commandPrefix = "git push" +``` + +In this configuration, the policy rule only triggers if the executing subagent's +name matches `pr-creator`. Rules without the `subagent` property apply +universally to all agents. + +## Managing subagents + +You can manage subagents interactively using the `/agents` command or +persistently via `settings.json`. + +### Interactive management (/agents) + +If you are in an interactive CLI session, you can use the `/agents` command to +manage subagents without editing configuration files manually. This is the +recommended way to quickly enable, disable, or re-configure agents on the fly. + +For a full list of sub-commands and usage, see the +[`/agents` command reference](../reference/commands.md#agents). + +### Persistent configuration (settings.json) + +While the `/agents` command and agent definition files provide a starting point, +you can use `settings.json` for global, persistent overrides. This is useful for +enforcing specific models or execution limits across all sessions. + +#### `agents.overrides` + +Use this to enable or disable specific agents or override their run +configurations. + +```json +{ + "agents": { + "overrides": { + "security-auditor": { + "enabled": false, + "runConfig": { + "maxTurns": 20, + "maxTimeMinutes": 10 + } + } + } + } +} +``` + +#### `modelConfigs.overrides` + +You can target specific subagents with custom model settings (like system +instruction prefixes or specific safety settings) using the `overrideScope` +field. + +```json +{ + "modelConfigs": { + "overrides": [ + { + "match": { "overrideScope": "security-auditor" }, + "modelConfig": { + "generateContentConfig": { + "temperature": 0.1 + } + } + } + ] + } +} +``` + +#### Safety policies (TOML) + +You can restrict access to specific subagents using the CLI's **Policy Engine**. +Subagents are treated as virtual tool names for policy matching purposes. + +To govern access to a subagent, create a `.toml` file in your policy directory +(e.g., `~/.gemini/policies/`): + +```toml +[[rule]] +toolName = "codebase_investigator" +decision = "deny" +deny_message = "Deep codebase analysis is restricted for this session." +``` + +For more information on setting up fine-grained safety guardrails, see the +[Policy Engine reference](../reference/policy-engine.md#special-syntax-for-subagents). + +### Optimizing your subagent + +The main agent's system prompt encourages it to use an expert subagent when one +is available. It decides whether an agent is a relevant expert based on the +agent's description. You can improve the reliability with which an agent is used +by updating the description to more clearly indicate: + +- Its area of expertise. +- When it should be used. +- Some example scenarios. + +For example, the following subagent description should be called fairly +consistently for Git operations. + +> Git expert agent which should be used for all local and remote git operations. +> For example: +> +> - Making commits +> - Searching for regressions with bisect +> - Interacting with source control and issues providers such as GitHub. + +If you need to further tune your subagent, you can do so by selecting the model +to optimize for with `/model` and then asking the model why it does not think +that your subagent was called with a specific prompt and the given description. + +## Remote subagents (Agent2Agent) + +Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent +(A2A) protocol. + +See the [Remote Subagents documentation](remote-agents) for detailed +configuration, authentication, and usage instructions. + +## Extension subagents + +Extensions can bundle and distribute subagents. See the +[Extensions documentation](../extensions/index.md#subagents) for details on how +to package agents within an extension. + +## Disabling subagents + +Subagents are enabled by default. To disable them, set `enableAgents` to `false` +in your `settings.json`: + +```json +{ + "experimental": { "enableAgents": false } +} +``` diff --git a/docs/examples/proxy-script.md b/docs/examples/proxy-script.md new file mode 100644 index 0000000000000000000000000000000000000000..35780d41efec80aaa21cccc0c4e3e524f8c464c6 --- /dev/null +++ b/docs/examples/proxy-script.md @@ -0,0 +1,83 @@ +# Example proxy script + +The following is an example of a proxy script that can be used with the +`GEMINI_SANDBOX_PROXY_COMMAND` environment variable. This script only allows +`HTTPS` connections to `example.com:443` and declines all other requests. + +```javascript +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Example proxy server that listens on :::8877 and only allows HTTPS connections to example.com. +// Set `GEMINI_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox +// Test via `curl https://example.com` inside sandbox (in shell mode or via shell tool) + +import http from 'node:http'; +import net from 'node:net'; +import { URL } from 'node:url'; +import console from 'node:console'; + +const PROXY_PORT = 8877; +const ALLOWED_DOMAINS = ['example.com', 'googleapis.com']; +const ALLOWED_PORT = '443'; + +const server = http.createServer((req, res) => { + // Deny all requests other than CONNECT for HTTPS + console.log( + `[PROXY] Denying non-CONNECT request for: ${req.method} ${req.url}`, + ); + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method Not Allowed'); +}); + +server.on('connect', (req, clientSocket, head) => { + // req.url will be in the format "hostname:port" for a CONNECT request. + const { port, hostname } = new URL(`http://${req.url}`); + + console.log(`[PROXY] Intercepted CONNECT request for: ${hostname}:${port}`); + + if ( + ALLOWED_DOMAINS.some( + (domain) => hostname == domain || hostname.endsWith(`.${domain}`), + ) && + port === ALLOWED_PORT + ) { + console.log(`[PROXY] Allowing connection to ${hostname}:${port}`); + + // Establish a TCP connection to the original destination. + const serverSocket = net.connect(port, hostname, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + // Create a tunnel by piping data between the client and the destination server. + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + + serverSocket.on('error', (err) => { + console.error(`[PROXY] Error connecting to destination: ${err.message}`); + clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\n\r\n`); + }); + } else { + console.log(`[PROXY] Denying connection to ${hostname}:${port}`); + clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + } + + clientSocket.on('error', (err) => { + // This can happen if the client hangs up. + console.error(`[PROXY] Client socket error: ${err.message}`); + }); +}); + +server.listen(PROXY_PORT, () => { + const address = server.address(); + console.log(`[PROXY] Proxy listening on ${address.address}:${address.port}`); + console.log( + `[PROXY] Allowing HTTPS connections to domains: ${ALLOWED_DOMAINS.join(', ')}`, + ); +}); +``` diff --git a/docs/extensions/best-practices.md b/docs/extensions/best-practices.md new file mode 100644 index 0000000000000000000000000000000000000000..ccd1652c8848261ac12c65d8fe0109055bac7bd5 --- /dev/null +++ b/docs/extensions/best-practices.md @@ -0,0 +1,189 @@ +# Gemini CLI extension best practices + +This guide covers best practices for developing, securing, and maintaining +Gemini CLI extensions. + +## Development + +Developing extensions for Gemini CLI is a lightweight, iterative process. Use +these strategies to build robust and efficient extensions. + +### Structure your extension + +While simple extensions may contain only a few files, we recommend a organized +structure for complex projects. + +```text +my-extension/ +β”œβ”€β”€ package.json +β”œβ”€β”€ tsconfig.json +β”œβ”€β”€ gemini-extension.json +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ index.ts +β”‚ └── tools/ +└── dist/ +``` + +- **Use TypeScript:** We strongly recommend using TypeScript for type safety and + improved developer experience. +- **Separate source and build:** Keep your source code in `src/` and output + build artifacts to `dist/`. +- **Bundle dependencies:** If your extension has many dependencies, bundle them + using a tool like `esbuild` to reduce installation time and avoid conflicts. + +### Iterate with `link` + +Use the `gemini extensions link` command to develop locally without reinstalling +your extension after every change. + +```bash +cd my-extension +gemini extensions link . +``` + +Changes to your code are immediately available in the CLI after you rebuild the +project and restart the session. + +### Use `GEMINI.md` effectively + +Your `GEMINI.md` file provides essential context to the model. + +- **Focus on goals:** Explain the high-level purpose of the extension and how to + interact with its tools. +- **Be concise:** Avoid dumping exhaustive documentation into the file. Use + clear, direct language. +- **Provide examples:** Include brief examples of how the model should use + specific tools or commands. + +## Security + +Follow the principle of least privilege and rigorous input validation when +building extensions. + +### Minimal permissions + +Only request the permissions your MCP server needs to function. Avoid giving the +model broad access (such as full shell access) if restricted tools are +sufficient. + +If your extension uses powerful tools like `run_shell_command`, restrict them in +your `gemini-extension.json` file: + +```json +{ + "name": "my-safe-extension", + "excludeTools": ["run_shell_command(rm -rf *)"] +} +``` + +This ensures the CLI blocks dangerous commands even if the model attempts to +execute them. + +### Validate inputs + +Your MCP server runs on the user's machine. Always validate tool inputs to +prevent arbitrary code execution or unauthorized filesystem access. + +```typescript +// Example: Validating paths +if (!path.resolve(inputPath).startsWith(path.resolve(allowedDir) + path.sep)) { + throw new Error('Access denied'); +} +``` + +### Secure sensitive settings + +If your extension requires API keys or other secrets, use the `sensitive: true` +option in your manifest. This ensures keys are stored in the system keychain and +obfuscated in the CLI output. + +```json +"settings": [ + { + "name": "API Key", + "envVar": "MY_API_KEY", + "sensitive": true + } +] +``` + +## Release + +Follow standard versioning and release practices to ensure a smooth experience +for your users. + +### Semantic versioning + +Follow [Semantic Versioning (SemVer)](https://semver.org/) to communicate +changes clearly. + +- **Major:** Breaking changes (for example, renaming tools or changing + arguments). +- **Minor:** New features (for example, adding new tools or commands). +- **Patch:** Bug fixes and performance improvements. + +### Release channels + +Use Git branches to manage release channels. This lets users choose between +stability and the latest features. + +```bash +# Install the stable version (default branch) +gemini extensions install github.com/user/repo + +# Install the development version +gemini extensions install github.com/user/repo --ref dev +``` + +### Clean artifacts + +When using GitHub Releases, ensure your archives only contain necessary files +(such as `dist/`, `gemini-extension.json`, and `package.json`). Exclude +`node_modules/` and `src/` to minimize download size. + +## Test and verify + +Test your extension thoroughly before releasing it to users. + +- **Manual verification:** Use `gemini extensions link` to test your extension + in a live CLI session. Verify that tools appear in the debug console (F12) and + that custom commands resolve correctly. +- **Automated testing:** If your extension includes an MCP server, write unit + tests for your tool logic using a framework like Vitest or Jest. You can test + MCP tools in isolation by mocking the transport layer. + +## Troubleshooting + +Use these tips to diagnose and fix common extension issues. + +### Extension not loading + +If your extension doesn't appear in `/extensions list`: + +- **Check the manifest:** Ensure `gemini-extension.json` is in the root + directory and contains valid JSON. +- **Verify the name:** The `name` field in the manifest must match the extension + directory name exactly. +- **Restart the CLI:** Extensions are loaded at the start of a session. Restart + Gemini CLI after making changes to the manifest or linking a new extension. + +### MCP server failures + +If your tools aren't working as expected: + +- **Check the logs:** View the CLI logs to see if the MCP server failed to + start. +- **Test the command:** Run the server's `command` and `args` directly in your + terminal to ensure it starts correctly outside of Gemini CLI. +- **Debug console:** In interactive mode, press **F12** to open the debug + console and inspect tool calls and responses. + +### Command conflicts + +If a custom command isn't responding: + +- **Check precedence:** Remember that user and project commands take precedence + over extension commands. Use the prefixed name (for example, + `/extension.command`) to verify the extension's version. +- **Help command:** Run `/help` to see a list of all available commands and + their sources. diff --git a/docs/extensions/index.md b/docs/extensions/index.md new file mode 100644 index 0000000000000000000000000000000000000000..1c6ce1e6997841f5e1a6220797f1c7c8f85afc96 --- /dev/null +++ b/docs/extensions/index.md @@ -0,0 +1,61 @@ +# Gemini CLI extensions + +Gemini CLI extensions package prompts, MCP servers, custom commands, themes, +hooks, sub-agents, and agent skills into a familiar and user-friendly format. +With extensions, you can expand the capabilities of Gemini CLI and share those +capabilities with others. They are designed to be easily installable and +shareable. + +To see what's possible, browse the +[Gemini CLI extension gallery](https://geminicli.com/extensions/browse/). + +## Choose your path + +Choose the guide that best fits your needs. + +### I want to use extensions + +Learn how to discover, install, and manage extensions to enhance your Gemini CLI +experience. + +- **[Manage extensions](#manage-extensions):** List and verify your installed + extensions. +- **[Install extensions](#installation):** Add new capabilities from GitHub or + local paths. + +### I want to build extensions + +Learn how to create, test, and share your own extensions with the community. + +- **[Build extensions](writing-extensions.md):** Create your first extension + from a template. +- **[Best practices](best-practices.md):** Learn how to build secure and + reliable extensions. +- **[Publish to the gallery](releasing.md):** Share your work with the world. + +## Manage extensions + +Use the interactive `/extensions` command to verify your installed extensions +and their status: + +```bash +/extensions list +``` + +You can also manage extensions from your terminal using the `gemini extensions` +command group: + +```bash +gemini extensions list +``` + +## Installation + +Install an extension by providing its GitHub repository URL. For example: + +```bash +gemini extensions install https://github.com/gemini-cli-extensions/workspace +``` + +For more advanced installation options, see the +[Extension reference](reference.md#install-an-extension). diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md new file mode 100644 index 0000000000000000000000000000000000000000..576265f2b1ef47022f9b19eee38371a1d05f07fa --- /dev/null +++ b/docs/extensions/reference.md @@ -0,0 +1,361 @@ +# Extension reference + +This guide covers the `gemini extensions` commands and the structure of the +`gemini-extension.json` configuration file. + +## Manage extensions + +Use the `gemini extensions` command group to manage your extensions from the +terminal. + +Note that commands like `gemini extensions install` are not supported within the +CLI's interactive mode. However, you can use the `/extensions list` command to +view installed extensions. All management operations, including updates to slash +commands, take effect only after you restart the CLI session. + +### Install an extension + +Install an extension by providing its GitHub repository URL or a local file +path. + +Gemini CLI creates a copy of the extension during installation. You must run +`gemini extensions update` to pull changes from the source. To install from +GitHub, you must have `git` installed on your machine. + +```bash +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] [--skip-settings] +``` + +- ``: The GitHub URL or local path of the extension. +- `--ref`: The git ref (branch, tag, or commit) to install. +- `--auto-update`: Enable automatic updates for this extension. +- `--pre-release`: Enable installation of pre-release versions. +- `--consent`: Acknowledge security risks and skip the confirmation prompt. +- `--skip-settings`: Skip the configuration on install process. + +### Uninstall an extension + +To uninstall one or more extensions, use the `uninstall` command: + +```bash +gemini extensions uninstall +``` + +### Disable an extension + +Extensions are enabled globally by default. You can disable an extension +entirely or for a specific workspace. + +```bash +gemini extensions disable [--scope ] +``` + +- ``: The name of the extension to disable. +- `--scope`: The scope to disable the extension in (`user` or `workspace`). + +### Enable an extension + +Re-enable a disabled extension using the `enable` command: + +```bash +gemini extensions enable [--scope ] +``` + +- ``: The name of the extension to enable. +- `--scope`: The scope to enable the extension in (`user` or `workspace`). + +### Update an extension + +Update an extension to the version specified in its `gemini-extension.json` +file. + +```bash +gemini extensions update +``` + +To update all installed extensions at once: + +```bash +gemini extensions update --all +``` + +### Create an extension from a template + +Create a new extension directory using a built-in template. + +```bash +gemini extensions new [template] +``` + +- ``: The directory to create. +- `[template]`: The template to use (for example, `mcp-server`, `context`, + `custom-commands`). + +### Link a local extension + +Create a symbolic link between your development directory and Gemini CLI +extensions directory. This lets you test changes immediately without +reinstalling. + +```bash +gemini extensions link +``` + +## Extension format + +Gemini CLI loads extensions from `/.gemini/extensions`. Each extension +must have a `gemini-extension.json` file in its root directory. + +### `gemini-extension.json` + +The manifest file defines the extension's behavior and configuration. + +```json +{ + "name": "my-extension", + "version": "1.0.0", + "description": "My awesome extension", + "mcpServers": { + "my-server": { + "command": "node", + "args": ["${extensionPath}/my-server.js"], + "cwd": "${extensionPath}" + } + }, + "contextFileName": "GEMINI.md", + "excludeTools": ["run_shell_command"], + "migratedTo": "https://github.com/new-owner/new-extension-repo", + "plan": { + "directory": ".gemini/plans" + } +} +``` + +- `name`: The name of the extension. This is used to uniquely identify the + extension and for conflict resolution when extension commands have the same + name as user or project commands. The name should be lowercase or numbers and + use dashes instead of underscores or spaces. This is how users will refer to + your extension in the CLI. Note that we expect this name to match the + extension directory name. +- `version`: The version of the extension. +- `description`: A short description of the extension. This will be displayed on + [geminicli.com/extensions](https://geminicli.com/extensions). +- `migratedTo`: The URL of the new repository source for the extension. If this + is set, the CLI will automatically check this new source for updates and + migrate the extension's installation to the new source if an update is found. +- `mcpServers`: A map of MCP servers to settings. The key is the name of the + server, and the value is the server configuration. These servers will be + loaded on startup just like MCP servers defined in a + [`settings.json` file](../reference/configuration.md). If both an extension + and a `settings.json` file define an MCP server with the same name, the server + defined in the `settings.json` file takes precedence. + - Note that all MCP server configuration options are supported except for + `trust`. + - For portability, you should use `${extensionPath}` to refer to files within + your extension directory. + - Separate your executable and its arguments using `command` and `args` + instead of putting them both in `command`. +- `contextFileName`: The name of the file that contains the context for the + extension. This will be used to load the context from the extension directory. + If this property is not used but a `GEMINI.md` file is present in your + extension directory, then that file will be loaded. +- `excludeTools`: An array of tool names to exclude from the model. You can also + specify command-specific restrictions for tools that support it, like the + `run_shell_command` tool. For example, + `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` + command. Note that this differs from the MCP server `excludeTools` + functionality, which can be listed in the MCP server config. +- `plan`: Planning features configuration. + - `directory`: The directory where planning artifacts are stored. This serves + as a fallback if the user hasn't specified a plan directory in their + settings. If not specified by either the extension or the user, the default + is `~/.gemini/tmp///plans/`. + +When Gemini CLI starts, it loads all the extensions and merges their +configurations. If there are any conflicts, the workspace configuration takes +precedence. + +### Extension settings + +Extensions can define settings that users provide during installation, such as +API keys or URLs. These values are stored in a `.env` file within the extension +directory. + +To define settings, add a `settings` array to your manifest: + +```json +{ + "name": "my-api-extension", + "version": "1.0.0", + "settings": [ + { + "name": "API Key", + "description": "Your API key for the service.", + "envVar": "MY_API_KEY", + "sensitive": true + } + ] +} +``` + +- `name`: The setting's display name. +- `description`: A clear explanation of the setting. +- `envVar`: The environment variable name where the value is stored. +- `sensitive`: If `true`, the value is stored in the system keychain and + obfuscated in the UI. + +To update an extension's settings: + +```bash +gemini extensions config [setting] [--scope ] +``` + +#### Environment variable sanitization + +For security reasons, sensitive environment variables are filtered out and not +passed to extensions or MCP servers by default. + +Extensions **will not** inherit the user's full shell environment variables. +They will only have access to: + +1. Standard safe variables (e.g., `HOME`, `PATH`, `TMPDIR`). +2. Variables explicitly declared and requested in the `gemini-extension.json` + manifest via the `settings` array (using the `envVar` property). + +If your extension requires specific environment variables (like an API key, +custom host, or config path), you **must** declare them in the `settings` array +so the CLI can allowlist them for use within the extension. + +### Custom commands + +Provide [custom commands](../cli/custom-commands.md) by placing TOML files in a +`commands/` subdirectory. Gemini CLI uses the directory structure to determine +the command name. + +For an extension named `gcp`: + +- `commands/deploy.toml` becomes `/deploy` +- `commands/gcs/sync.toml` becomes `/gcs:sync` (namespaced with a colon) + +### Hooks + +Intercept and customize CLI behavior using [hooks](../hooks/index.md). Define +hooks in a `hooks/hooks.json` file within your extension directory. Note that +hooks are not defined in the `gemini-extension.json` manifest. + +### Agent skills + +Bundle [agent skills](../cli/skills.md) to provide specialized workflows. Place +skill definitions in a `skills/` directory. For example, +`skills/security-audit/SKILL.md` exposes a `security-audit` skill. + +### Sub-agents + + +> [!NOTE] +> Sub-agents are a preview feature currently under active development. + +Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add +agent definition files (`.md`) to an `agents/` directory in your extension root. + +### Policy Engine + +Extensions can contribute policy rules and safety checkers to Gemini CLI +[Policy Engine](../reference/policy-engine.md). These rules are defined in +`.toml` files and take effect when the extension is activated. + +To add policies, create a `policies/` directory in your extension's root and +place your `.toml` policy files inside it. Gemini CLI automatically loads all +`.toml` files from this directory. + +Rules contributed by extensions run in their own tier (tier 2), alongside +workspace-defined policies. This tier has higher priority than the default rules +but lower priority than user or admin policies. + + +> [!WARNING] +> For security, Gemini CLI ignores any `allow` decisions or `yolo` +> mode configurations in extension policies. This ensures that an extension +> cannot automatically approve tool calls or bypass security measures without +> your confirmation. + +**Example `policies.toml`** + +```toml +[[rule]] +mcpName = "my_server" +toolName = "dangerous_tool" +decision = "ask_user" +priority = 100 + +[[safety_checker]] +mcpName = "my_server" +toolName = "write_data" +priority = 200 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] +``` + +### Themes + +Extensions can provide custom themes to personalize the CLI UI. Themes are +defined in the `themes` array in `gemini-extension.json`. + +**Example** + +```json +{ + "name": "my-green-extension", + "version": "1.0.0", + "themes": [ + { + "name": "shades-of-green", + "type": "custom", + "background": { + "primary": "#1a362a" + }, + "text": { + "primary": "#a6e3a1", + "secondary": "#6e8e7a", + "link": "#89e689" + }, + "status": { + "success": "#76c076", + "warning": "#d9e689", + "error": "#b34e4e" + }, + "border": { + "default": "#4a6c5a" + }, + "ui": { + "comment": "#6e8e7a" + } + } + ] +} +``` + +Custom themes provided by extensions can be selected using the `/theme` command +or by setting the `ui.theme` property in your `settings.json` file. Note that +when referring to a theme from an extension, the extension name is appended to +the theme name in parentheses, for example, +`shades-of-green (my-green-extension)`. + +### Conflict resolution + +Extension commands have the lowest precedence. If an extension command name +conflicts with a user or project command, the extension command is prefixed with +the extension name (for example, `/gcp.deploy`) using a dot separator. + +## Variables + +Gemini CLI supports variable substitution in `gemini-extension.json` and +`hooks/hooks.json`. + +| Variable | Description | +| :----------------- | :---------------------------------------------- | +| `${extensionPath}` | The absolute path to the extension's directory. | +| `${workspacePath}` | The absolute path to the current workspace. | +| `${/}` | The platform-specific path separator. | diff --git a/docs/extensions/releasing.md b/docs/extensions/releasing.md new file mode 100644 index 0000000000000000000000000000000000000000..9746af86c1e4a4f7a0e56a67584883a270b9aa3a --- /dev/null +++ b/docs/extensions/releasing.md @@ -0,0 +1,215 @@ +# Release extensions + +Release Gemini CLI extensions to your users through a Git repository or GitHub +Releases. This guide explains how to share your work, list it in the gallery, +and manage updates. + +Git repository releases are the simplest approach and offer the most flexibility +for managing development branches. GitHub Releases are more efficient for +initial installations because they ship as single archives rather than requiring +a full `git clone`. Use GitHub Releases if you need to include platform-specific +binary files. + +## List your extension in the gallery + +The [Gemini CLI extension gallery](https://geminicli.com/extensions/browse/) +automatically indexes public extensions to help users discover your work. You +don't need to submit an issue or email us to list your extension. + +To have your extension automatically discovered and listed: + +1. **Use a public repository:** Ensure your extension is hosted in a public + GitHub repository. +2. **Add the GitHub topic:** Add the `gemini-cli-extension` topic to your + repository's **About** section. Our crawler uses this topic to find new + extensions. +3. **Place the manifest at the root:** Ensure your `gemini-extension.json` file + is in the absolute root of the repository or the release archive. + +Our system crawls tagged repositories daily. Once you tag your repository, your +extension will appear in the gallery if it passes validation. + +## Release through a Git repository + +Releasing through Git is the most flexible option. Create a public Git +repository and provide the URL to your users. They can then install your +extension using `gemini extensions install `. + +Users can optionally depend on a specific branch, tag, or commit using the +`--ref` argument. For example: + +```bash +gemini extensions install --ref=stable +``` + +Whenever you push commits to the referenced branch, the CLI prompts users to +update their installation. The `HEAD` commit is always treated as the latest +version. + +### Manage release channels + +You can use branches or tags to manage different release channels, such as +`stable`, `preview`, or `dev`. + +We recommend using your default branch as the stable release channel. This +ensures that the default installation command always provides the most reliable +version of your extension. You can then use a `dev` branch for active +development and merge it into the default branch when you are ready for a +release. + +## Release through GitHub Releases + +Distributing extensions through +[GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases) +provides a faster installation experience by avoiding a repository clone. + +Gemini CLI checks for updates by looking for the **Latest** release on GitHub. +Users can also install specific versions using the `--ref` argument with a +release tag. Use the `--pre-release` flag to install the latest version even if +it isn't marked as **Latest**. + +### Custom pre-built archives + +You can attach custom archives directly to your GitHub Release as assets. This +is useful if your extension requires a build step or includes platform-specific +binaries. + +Custom archives must be fully self-contained and follow the required +[archive structure](#archive-structure). If your extension is +platform-independent, provide a single generic asset. + +#### Platform-specific archives + +To let Gemini CLI find the correct asset for a user's platform, use the +following naming convention: + +1. **Platform and architecture-specific:** + `{platform}.{arch}.{name}.{extension}` +2. **Platform-specific:** `{platform}.{name}.{extension}` +3. **Generic:** A single asset will be used as a fallback if no specific match + is found. + +Use these values for the placeholders: + +- `{name}`: Your extension name. +- `{platform}`: Use `darwin` (macOS), `linux`, or `win32` (Windows). +- `{arch}`: Use `x64` or `arm64`. +- `{extension}`: Use `.tar.gz` or `.zip`. + +**Examples:** + +- `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs) +- `darwin.my-tool.tar.gz` (fallback for all Macs, for example Intel) +- `linux.x64.my-tool.tar.gz` +- `win32.my-tool.zip` + +#### Archive structure + +Archives must be fully contained extensions. The `gemini-extension.json` file +must be at the root of the archive. The rest of the layout should match a +standard extension structure. + +#### Example GitHub Actions workflow + +Use this example workflow to build and release your extension for multiple +platforms: + +```yaml +name: Release Extension + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Build extension + run: npm run build + + - name: Create release assets + run: | + npm run package -- --platform=darwin --arch=arm64 + npm run package -- --platform=linux --arch=x64 + npm run package -- --platform=win32 --arch=x64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: | + release/darwin.arm64.my-tool.tar.gz + release/linux.arm64.my-tool.tar.gz + release/win32.arm64.my-tool.zip +``` + +## Migrate an extension repository + +If you move your extension to a new repository or rename it, use the +`migratedTo` property in `gemini-extension.json` to seamlessly transition your +users. + +1. **Create the new repository:** Set up your extension in its new location. +2. **Update the old repository:** In your original repository, update the + `gemini-extension.json` file to include the `migratedTo` property pointing + to the new repository URL, and increment the version number. + ```json + { + "name": "my-extension", + "version": "1.1.0", + "migratedTo": "https://github.com/new-owner/new-extension-repo" + } + ``` +3. **Release the update:** Publish this new version in your old repository. + +When users check for updates, Gemini CLI detects the `migratedTo` field, +verifies the new repository, and automatically updates their local installation +to track the new source. All settings migrate automatically. + +## How updates work + +Gemini CLI automatically checks for extension updates based on the installation +method. Understanding these mechanisms helps you ensure your users always have +the latest version. + +### Sync manifest and tags + +For GitHub releases, always ensure the `version` in `gemini-extension.json` +matches your GitHub release tag. While the CLI uses tags for update detection, +it displays the manifest version in the UI. Keeping them in sync prevents +confusion. + +### Update mechanisms + +
+Technical update details + +The CLI uses different strategies depending on the installation type: + +- **GitHub releases:** The CLI queries the GitHub API for the latest release + tag. It ignores the `version` field in the manifest for detection. +- **Git clones:** The CLI runs `git ls-remote` to compare the latest remote + commit hash with your local `HEAD`. +- **Local extensions:** The CLI compares the `version` field in the source + directory's manifest with the installed version. + +To verify an extension's installation type, inspect the `type` field in the +metadata file at `~/.gemini/extensions//.gemini-extension-install.json`. + +
+ + +> [!IMPORTANT] +> The `migratedTo` flow requires at least one release on the new repository for +> the CLI to recognize it as a valid update source. diff --git a/docs/extensions/writing-extensions.md b/docs/extensions/writing-extensions.md new file mode 100644 index 0000000000000000000000000000000000000000..0ad0440dff96b9cdb25fe5a56778c57beca1d75d --- /dev/null +++ b/docs/extensions/writing-extensions.md @@ -0,0 +1,315 @@ +# Build Gemini CLI extensions + +Gemini CLI extensions let you expand the capabilities of Gemini CLI by adding +custom tools, commands, and context. This guide walks you through creating your +first extension, from setting up a template to adding custom functionality and +linking it for local development. + +## Prerequisites + +Before you start, ensure you have Gemini CLI installed and a basic understanding +of Node.js. + +## Extension features + +Extensions offer several ways to customize Gemini CLI. Use this table to decide +which features your extension needs. + +| Feature | What it is | When to use it | Invoked by | +| :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- | +| **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model | +| **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User | +| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model | +| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model | +| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (for example, before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI | +| **[Custom themes](reference.md#themes)** | A set of color definitions to personalize the CLI UI. | Use this to provide a unique visual identity for your extension or to offer specialized high-contrast or thematic color schemes. | User (via /theme) | + +## Step 1: Create a new extension + +The easiest way to start is by using a built-in template. We'll use the +`mcp-server` example as our foundation. + +Run the following command to create a new directory called `my-first-extension` +with the template files: + +```bash +gemini extensions new my-first-extension mcp-server +``` + +This creates a directory with the following structure: + +``` +my-first-extension/ +β”œβ”€β”€ example.js +β”œβ”€β”€ gemini-extension.json +└── package.json +``` + +## Step 2: Understand the extension files + +Your new extension contains several key files that define its behavior. + +### `gemini-extension.json` + +The manifest file tells Gemini CLI how to load and use your extension. + +```json +{ + "name": "mcp-server-example", + "version": "1.0.0", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}example.js"], + "cwd": "${extensionPath}" + } + } +} +``` + +- `name`: The unique name for your extension. +- `version`: The version of your extension. +- `mcpServers`: Defines Model Context Protocol (MCP) servers to add new tools. + - `command`, `args`, `cwd`: Specify how to start your server. The + `${extensionPath}` variable is replaced with the absolute path to your + extension's directory. + +### `example.js` + +This file contains the source code for your MCP server. It uses the +`@modelcontextprotocol/sdk` to define tools. + +```javascript +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: 'prompt-server', + version: '1.0.0', +}); + +// Registers a new tool named 'fetch_posts' +server.registerTool( + 'fetch_posts', + { + description: 'Fetches a list of posts from a public API.', + inputSchema: z.object({}).shape, + }, + async () => { + const apiResponse = await fetch( + 'https://jsonplaceholder.typicode.com/posts', + ); + const posts = await apiResponse.json(); + const response = { posts: posts.slice(0, 5) }; + return { + content: [ + { + type: 'text', + text: JSON.stringify(response), + }, + ], + }; + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +### `package.json` + +The standard configuration file for a Node.js project. It defines dependencies +and scripts for your extension. + +## Step 3: Add extension settings + +Some extensions need configuration, such as API keys or user preferences. Let's +add a setting for an API key. + +1. Open `gemini-extension.json`. +2. Add a `settings` array to the configuration: + + ```json + { + "name": "mcp-server-example", + "version": "1.0.0", + "settings": [ + { + "name": "API Key", + "description": "The API key for the service.", + "envVar": "MY_SERVICE_API_KEY", + "sensitive": true + } + ], + "mcpServers": { + // ... + } + } + ``` + +When a user installs this extension, Gemini CLI will prompt them to enter the +"API Key". The value will be stored securely in the system keychain (because +`sensitive` is true) and injected into the MCP server's process as the +`MY_SERVICE_API_KEY` environment variable. + +> **Important (Environment Variable Sanitization):** For security reasons, +> sensitive environment variables are filtered out and not passed to extensions +> or MCP servers by default. Extensions will _only_ have access to environment +> variables that are explicitly declared in the `settings` array using the +> `envVar` property, plus a few standard safe variables. Do not expect host +> environment variables to be available otherwise. + +## Step 4: Link your extension + +Link your extension to your Gemini CLI installation for local development. + +1. **Install dependencies:** + + ```bash + cd my-first-extension + npm install + ``` + +2. **Link the extension:** + + The `link` command creates a symbolic link from Gemini CLI extensions + directory to your development directory. Changes you make are reflected + immediately. + + ```bash + gemini extensions link . + ``` + +Restart your Gemini CLI session to use the new `fetch_posts` tool. Test it by +asking: "fetch posts". + +## Step 5: Add a custom command + +Custom commands create shortcuts for complex prompts. + +1. Create a `commands` directory and a subdirectory for your command group: + + **macOS/Linux** + + ```bash + mkdir -p commands/fs + ``` + + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "commands\fs" + ``` + +2. Create a file named `commands/fs/grep-code.toml`: + + ```toml + prompt = """ + Please summarize the findings for the pattern `{{args}}`. + + Search Results: + !{grep -r {{args}} .} + """ + ``` + + This command, `/fs:grep-code`, takes an argument, runs the `grep` shell + command, and pipes the results into a prompt for summarization. + +After saving the file, restart Gemini CLI. Run `/fs:grep-code "some pattern"` to +use your new command. + +## Step 6: Add a custom `GEMINI.md` + +Provide persistent context to the model by adding a `GEMINI.md` file to your +extension. This is useful for setting behavior or providing essential tool +information. + +1. Create a file named `GEMINI.md` in the root of your extension directory: + + ```markdown + # My First Extension Instructions + + You are an expert developer assistant. When the user asks you to fetch + posts, use the `fetch_posts` tool. Be concise in your responses. + ``` + +2. Update your `gemini-extension.json` to load this file: + + ```json + { + "name": "my-first-extension", + "version": "1.0.0", + "contextFileName": "GEMINI.md", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}example.js"], + "cwd": "${extensionPath}" + } + } + } + ``` + +Restart Gemini CLI. The model now has the context from your `GEMINI.md` file in +every session where the extension is active. + +## (Optional) Step 7: Add an Agent Skill + +[Agent Skills](../cli/skills.md) bundle specialized expertise and workflows. +Skills are activated only when needed, which saves context tokens. + +1. Create a `skills` directory and a subdirectory for your skill: + + **macOS/Linux** + + ```bash + mkdir -p skills/security-audit + ``` + + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "skills\security-audit" + ``` + +2. Create a `skills/security-audit/SKILL.md` file: + + ```markdown + --- + name: security-audit + description: + Expertise in auditing code for security vulnerabilities. Use when the user + asks to "check for security issues" or "audit" their changes. + --- + + # Security Auditor + + You are an expert security researcher. When auditing code: + + 1. Look for common vulnerabilities (OWASP Top 10). + 2. Check for hardcoded secrets or API keys. + 3. Suggest remediation steps for any findings. + ``` + +Gemini CLI automatically discovers skills bundled with your extension. The model +activates them when it identifies a relevant task. + +## Step 8: Release your extension + +When your extension is ready, share it with others via a Git repository or +GitHub Releases. Refer to the [Extension Releasing Guide](./releasing.md) for +detailed instructions and learn how to list your extension in the gallery. + +## Next steps + +- [Extension reference](reference.md): Deeply understand the extension format, + commands, and configuration. +- [Best practices](best-practices.md): Learn strategies for building great + extensions. diff --git a/docs/get-started/authentication.mdx b/docs/get-started/authentication.mdx new file mode 100644 index 0000000000000000000000000000000000000000..f725ecdcf4efb438a94d363eba3a08584c7a859e --- /dev/null +++ b/docs/get-started/authentication.mdx @@ -0,0 +1,462 @@ +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +# Gemini CLI authentication setup + +To use Gemini CLI, you'll need to authenticate with Google. This guide helps you +quickly find the best way to sign in based on your account type and how you're +using the CLI. + +> [!TIP] +> Looking for a high-level comparison of all available subscriptions? +> To compare features and find the right quota for your needs, see our +> [Plans page](https://geminicli.com/plans/). + +For most users, we recommend starting Gemini CLI and logging in with your +personal Google account. + +## Choose your authentication method + +Select the authentication method that matches your situation in the table below: + +| User Type / Scenario | Recommended Authentication Method | Google Cloud Project Required | +| :--------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------- | +| Individual Google accounts | [Sign in with Google](#login-google) | No, with exceptions | +| Organization users with a company, school, or Google Workspace account | [Sign in with Google](#login-google) | [Yes](#set-gcp) | +| AI Studio user with a Gemini API key | [Use Gemini API Key](#gemini-api) | No | +| Google Cloud Vertex AI user | [Vertex AI](#vertex-ai) | [Yes](#set-gcp) | +| [Headless mode](#headless) | [Use Gemini API Key](#gemini-api) or
[Vertex AI](#vertex-ai) | No (for Gemini API Key)
[Yes](#set-gcp) (for Vertex AI) | + +### What is my Google account type? + +- **Individual Google accounts:** Includes all + [free tier accounts](../resources/quota-and-pricing.md#free-usage) such as + Gemini Code Assist for individuals, as well as paid subscriptions for + [Google AI Pro and Ultra](https://gemini.google/subscriptions/). + +- **Organization accounts:** Accounts using paid licenses through an + organization such as a company, school, or + [Google Workspace](https://workspace.google.com/). Includes + [Google AI Ultra for Business](https://support.google.com/a/answer/16345165) + subscriptions. + +## (Recommended) Sign in with Google + +If you run Gemini CLI on your local machine, the simplest authentication method +is logging in with your Google account. This method requires a web browser on a +machine that can communicate with the terminal running Gemini CLI (for example, +your local machine). + +If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google +account associated with your subscription. + +To authenticate and use Gemini CLI: + +1. Start the CLI: + + ```bash + gemini + ``` + +2. Select **Sign in with Google**. Gemini CLI opens a sign in prompt using your + web browser. Follow the on-screen instructions. Your credentials will be + cached locally for future sessions. + +### Do I need to set my Google Cloud project? + +Most individual Google accounts (free and paid) don't require a Google Cloud +project for authentication. However, you'll need to set a Google Cloud project +when you meet at least one of the following conditions: + +- You are using a company, school, or Google Workspace account. +- You are using a Gemini Code Assist license from the Google Developer Program. +- You are using a license from a Gemini Code Assist subscription. + +For instructions, see [Set your Google Cloud Project](#set-gcp). + +## Use Gemini API key + +If you don't want to authenticate using your Google account, you can use an API +key from Google AI Studio. + +To authenticate and use Gemini CLI with a Gemini API key: + +1. Obtain your API key from + [Google AI Studio](https://aistudio.google.com/app/apikey). + +2. Set the `GEMINI_API_KEY` environment variable to your key. For example: + + + + + ```bash + # Replace YOUR_GEMINI_API_KEY with the key from AI Studio + export GEMINI_API_KEY="YOUR_GEMINI_API_KEY" + ``` + + + + + ```powershell + # Replace YOUR_GEMINI_API_KEY with the key from AI Studio + $env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY" + ``` + + + + + To make this setting persistent, see + [Persisting Environment Variables](#persisting-vars). + +3. Start the CLI: + + ```bash + gemini + ``` + +4. Select **Use Gemini API key**. + +> [!WARNING] +> Treat API keys, especially for services like Gemini, as sensitive +> credentials. Protect them to prevent unauthorized access and potential misuse +> of the service under your account. + +## Use Vertex AI + +To use Gemini CLI with Google Cloud's Vertex AI platform, choose from the +following authentication options: + +- A. Application Default Credentials (ADC) using `gcloud`. +- B. Service account JSON key. +- C. Google Cloud API key. + +Regardless of your authentication method for Vertex AI, you'll need to set +`GOOGLE_CLOUD_PROJECT` to your Google Cloud project ID with the Vertex AI API +enabled, and `GOOGLE_CLOUD_LOCATION` to the [location of your Vertex AI resources](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations) +or the location where you want to run your jobs. + +For example: + + + + + ```bash +# Replace with your project ID and desired location (for example, us-central1) +export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" +export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" + ``` + + + + + ```powershell +# Replace with your project ID and desired location (for example, us-central1) +$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" +$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" + ``` + + + + +To make any Vertex AI environment variable settings persistent, see +[Persisting Environment Variables](#persisting-vars). + +#### A. Vertex AI - application default credentials (ADC) using `gcloud` + +Consider this authentication method if you have Google Cloud CLI installed. + +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them to use ADC. + + + + + ```bash +unset GOOGLE_API_KEY GEMINI_API_KEY + ``` + + + + + ```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore + ``` + + + + +1. Verify you have a Google Cloud project and Vertex AI API is enabled. + +2. Log in to Google Cloud: + + ```bash + gcloud auth application-default login + ``` + +3. [Configure your Google Cloud Project](#set-gcp). + +4. Start the CLI: + + ```bash + gemini + ``` + +5. Select **Vertex AI**. + +#### B. Vertex AI - service account JSON key + +Consider this method of authentication in non-interactive environments, CI/CD +pipelines, or if your organization restricts user-based ADC or API key creation. + +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them: + + + + + ```bash +unset GOOGLE_API_KEY GEMINI_API_KEY + ``` + + + + + ```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore + ``` + + + + +1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) + and download the provided JSON file. Assign the "Vertex AI User" role to the + service account. + +2. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the JSON + file's absolute path. For example: + + + + + ```bash + # Replace /path/to/your/keyfile.json with the actual path + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/keyfile.json" + ``` + + + + + ```powershell + # Replace C:\path\to\your\keyfile.json with the actual path + $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\keyfile.json" + ``` + + + + +3. [Configure your Google Cloud Project](#set-gcp). + +4. Start the CLI: + + ```bash + gemini + ``` + +5. Select **Vertex AI**. + +> [!WARNING] +> Protect your service account key file as it gives access to +> your resources. + +#### C. Vertex AI - Google Cloud API key + +1. Obtain a Google Cloud API key: + [Get an API Key](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys?usertype=newuser). + +2. Set the `GOOGLE_API_KEY` environment variable: + + + + + ```bash + # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key + export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" + ``` + + + + + ```powershell + # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key + $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" + ``` + + + + + If you see errors like `"API keys are not supported by this API..."`, your + organization might restrict API key usage for this service. Try the other + Vertex AI authentication methods instead. + +3. [Configure your Google Cloud Project](#set-gcp). + +4. Start the CLI: + + ```bash + gemini + ``` + +5. Select **Vertex AI**. + +## Set your Google Cloud project + +> [!IMPORTANT] +> Most individual Google accounts (free and paid) don't require a +> Google Cloud project for authentication. + +When you sign in using your Google account, you may need to configure a Google +Cloud project for Gemini CLI to use. This applies when you meet at least one of +the following conditions: + +- You are using a Company, School, or Google Workspace account. +- You are using a Gemini Code Assist license from the Google Developer Program. +- You are using a license from a Gemini Code Assist subscription. + +To configure Gemini CLI to use a Google Cloud project, do the following: + +1. [Find your Google Cloud Project ID](https://support.google.com/googleapi/answer/7014113). + +2. [Enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api). + +3. [Configure necessary IAM access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam). + +4. Configure your environment variables. Set either the `GOOGLE_CLOUD_PROJECT` + or `GOOGLE_CLOUD_PROJECT_ID` variable to the project ID to use with Gemini + CLI. Gemini CLI checks for `GOOGLE_CLOUD_PROJECT` first, then falls back to + `GOOGLE_CLOUD_PROJECT_ID`. + + For example, to set the `GOOGLE_CLOUD_PROJECT_ID` variable: + + + + + ```bash + # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID + export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" + ``` + + + + + ```powershell + # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID + $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" + ``` + + + + + To make this setting persistent, see + [Persisting Environment Variables](#persisting-vars). + +## Persisting environment variables + +To avoid setting environment variables for every terminal session, you can +persist them with the following methods: + +1. **Add your environment variables to your shell configuration file:** Append + the environment variable commands to your shell's startup file. + + + + + (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`): + + ```bash + echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc + source ~/.bashrc + ``` + + + + + (for example, `$PROFILE`): + + ```powershell + Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' + . $PROFILE + ``` + + + + +> [!WARNING] +> Be aware that when you export API keys or service account +> paths in your shell configuration file, any process launched from that +> shell can read them. + +2. **Use a `.env` file:** Create a `.gemini/.env` file in your project + directory or home directory. Gemini CLI automatically loads variables from + the first `.env` file it finds, searching up from the current directory, + then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env` + or `%USERPROFILE%\.gemini\.env`). + + Example for user-wide settings: + + + + + ```bash + mkdir -p ~/.gemini + cat >> ~/.gemini/.env <<'EOF' + GOOGLE_CLOUD_PROJECT="your-project-id" + # Add other variables like GEMINI_API_KEY as needed + EOF + ``` + + + + + ```powershell + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini" + @" + GOOGLE_CLOUD_PROJECT="your-project-id" + # Add other variables like GEMINI_API_KEY as needed + "@ | Out-File -FilePath "$env:USERPROFILE\.gemini\.env" -Encoding utf8 -Append + ``` + + + + +Variables are loaded from the first file found, not merged. + +## Running in Google Cloud environments + +When running Gemini CLI within certain Google Cloud environments, authentication +is automatic. + +In a Google Cloud Shell environment, Gemini CLI typically authenticates +automatically using your Cloud Shell credentials. In Compute Engine +environments, Gemini CLI automatically uses Application Default Credentials +(ADC) from the environment's metadata server. + +If automatic authentication fails, use one of the interactive methods described +on this page. + +## Running in headless mode + +[Headless mode](../cli/headless.md) will use your existing authentication +method, if an existing authentication credential is cached. + +If you have not already signed in with an authentication credential, you must +configure authentication using environment variables: + +- [Use Gemini API Key](#gemini-api) +- [Vertex AI](#vertex-ai) + +## What's next? + +Your authentication method affects your quotas, pricing, Terms of Service, and +privacy notices. Review the following pages to learn more: + +- [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md). +- [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md). diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md new file mode 100644 index 0000000000000000000000000000000000000000..1a4dd46de1fb69b914bf9d2bc078588d26c68879 --- /dev/null +++ b/docs/hooks/best-practices.md @@ -0,0 +1,710 @@ +# Hooks Best Practices + +This guide covers security considerations, performance optimization, debugging +techniques, and privacy considerations for developing and deploying hooks in +Gemini CLI. + +## Performance + +### Keep hooks fast + +Hooks run synchronouslyβ€”slow hooks delay the agent loop. Optimize for speed by +using parallel operations: + +```javascript +// Sequential operations are slower +const data1 = await fetch(url1).then((r) => r.json()); +const data2 = await fetch(url2).then((r) => r.json()); + +// Prefer parallel operations for better performance +// Start requests concurrently +const p1 = fetch(url1).then((r) => r.json()); +const p2 = fetch(url2).then((r) => r.json()); + +// Wait for all results +const [data1, data2] = await Promise.all([p1, p2]); +``` + +### Cache expensive operations + +Store results between invocations to avoid repeated computation, especially for +hooks that run frequently (like `BeforeTool` or `AfterModel`). + +```javascript +const fs = require('fs'); +const path = require('path'); + +const CACHE_FILE = '.gemini/hook-cache.json'; + +function readCache() { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + } catch { + return {}; + } +} + +function writeCache(data) { + fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); +} + +async function main() { + const cache = readCache(); + const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache + + if (cache[cacheKey]) { + // Write JSON to stdout + console.log(JSON.stringify(cache[cacheKey])); + return; + } + + // Expensive operation + const result = await computeExpensiveResult(); + cache[cacheKey] = result; + writeCache(cache); + + console.log(JSON.stringify(result)); +} +``` + +### Use appropriate events + +Choose hook events that match your use case to avoid unnecessary execution. + +- **`AfterAgent`**: Fires **once** per turn after the model finishes its final + response. Use this for quality validation (Retries) or final logging. +- **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for + real-time redaction, PII filtering, or monitoring output as it streams. + +If you only need to check the final completion, use `AfterAgent` to save +performance. + +### Filter with matchers + +Use specific matchers to avoid unnecessary hook execution. Instead of matching +all tools with `*`, specify only the tools you need. This saves the overhead of +spawning a process for irrelevant events. + +```json +{ + "matcher": "write_file|replace", + "hooks": [ + { + "name": "validate-writes", + "type": "command", + "command": "./validate.sh" + } + ] +} +``` + +### Optimize JSON parsing + +For large inputs (like `AfterModel` receiving a large context), standard JSON +parsing can be slow. If you only need one field, consider streaming parsers or +lightweight extraction logic, though for most shell scripts `jq` is sufficient. + +## Debugging + +### The "Strict JSON" rule + +The most common cause of hook failure is "polluting" the standard output. + +- **stdout** is for **JSON only**. +- **stderr** is for **logs and text**. + +**Good:** + +```bash +#!/bin/bash +echo "Starting check..." >&2 # <--- Redirect to stderr +echo '{"decision": "allow"}' + +``` + +### Log to files + +Since hooks run in the background, writing to a dedicated log file is often the +easiest way to debug complex logic. + +```bash +#!/usr/bin/env bash +LOG_FILE=".gemini/hooks/debug.log" + +# Log with timestamp +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" +} + +input=$(cat) +log "Received input: ${input:0:100}..." + +# Hook logic here + +log "Hook completed successfully" +# Always output valid JSON to stdout at the end, even if just empty +echo "{}" + +``` + +### Use stderr for errors + +Error messages on stderr are surfaced appropriately based on exit codes: + +```javascript +try { + const result = dangerousOperation(); + console.log(JSON.stringify({ result })); +} catch (error) { + // Write the error description to stderr so the user/agent sees it + console.error(`Hook error: ${error.message}`); + process.exit(2); // Blocking error +} +``` + +### Test hooks independently + +Run hook scripts manually with sample JSON input to verify they behave as +expected before hooking them up to the CLI. + +**macOS/Linux** + +```bash +# Create test input +cat > test-input.json << 'EOF' +{ + "session_id": "test-123", + "cwd": "/tmp/test", + "hook_event_name": "BeforeTool", + "tool_name": "write_file", + "tool_input": { + "file_path": "test.txt", + "content": "Test content" + } +} +EOF + +# Test the hook +cat test-input.json | .gemini/hooks/my-hook.sh + +# Check exit code +echo "Exit code: $?" +``` + +**Windows (PowerShell)** + +```powershell +# Create test input +@" +{ + "session_id": "test-123", + "cwd": "C:\\temp\\test", + "hook_event_name": "BeforeTool", + "tool_name": "write_file", + "tool_input": { + "file_path": "test.txt", + "content": "Test content" + } +} +"@ | Out-File -FilePath test-input.json -Encoding utf8 + +# Test the hook +Get-Content test-input.json | .\.gemini\hooks\my-hook.ps1 + +# Check exit code +Write-Host "Exit code: $LASTEXITCODE" +``` + +### Check exit codes + +Gemini CLI uses exit codes for high-level flow control: + +- **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for + JSON decisions. +- **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the + reason. + - For **Agent/Model** events, this aborts the turn. + - For **Tool** events, this blocks the tool but allows the agent to continue. + - For **AfterAgent**, this triggers an automatic retry turn. + +> **TIP** +> +> **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a +> **specific action**. Use `{"continue": false}` in your JSON output to **kill +> the entire agent loop** immediately. + +```bash +#!/usr/bin/env bash +set -e + +# Hook logic +if process_input; then + echo '{"decision": "allow"}' + exit 0 +else + echo "Critical validation failure" >&2 + exit 2 +fi + +``` + +### Enable telemetry + +Hook execution is logged when `telemetry.logPrompts` is enabled. You can view +these logs to debug execution flow. + +```json +{ + "telemetry": { + "logPrompts": true + } +} +``` + +### Use hook panel + +The `/hooks panel` command inside the CLI shows execution status and recent +output: + +```bash +/hooks panel +``` + +Check for: + +- Hook execution counts +- Recent successes/failures +- Error messages +- Execution timing + +## Development + +### Start simple + +Begin with basic logging hooks before implementing complex logic: + +```bash +#!/usr/bin/env bash +# Simple logging hook to understand input structure +input=$(cat) +echo "$input" >> .gemini/hook-inputs.log +# Always return valid JSON +echo "{}" + +``` + +### Documenting your hooks + +Maintainability is critical for complex hook systems. Use descriptions and +comments to help yourself and others understand why a hook exists. + +**Use the `description` field**: This text is displayed in the `/hooks panel` UI +and helps diagnose issues. + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "write_file|replace", + "hooks": [ + { + "name": "secret-scanner", + "type": "command", + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", + "description": "Scans code changes for API keys and secrets before writing" + } + ] + } + ] + } +} +``` + +**Add comments in hook scripts**: Explain performance expectations and +dependencies. + +```javascript +#!/usr/bin/env node +/** + * RAG Tool Filter Hook + * + * Reduces the tool space by extracting keywords from the user's request. + * + * Performance: ~500ms average + * Dependencies: @google/generative-ai + */ +``` + +### Use JSON libraries + +Parse JSON with proper libraries instead of text processing. + +**Bad:** + +```bash +# Fragile text parsing +tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') + +``` + +**Good:** + +```bash +# Robust JSON parsing +tool_name=$(echo "$input" | jq -r '.tool_name') + +``` + +### Make scripts executable + +Always make hook scripts executable on macOS/Linux: + +```bash +chmod +x .gemini/hooks/*.sh +chmod +x .gemini/hooks/*.js + +``` + +**Windows Note**: On Windows, PowerShell scripts (`.ps1`) don't use `chmod`, but +you may need to ensure your execution policy allows them to run (for example, +`Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`). + +### Version control + +Commit hooks to share with your team: + +```bash +git add .gemini/hooks/ +git add .gemini/settings.json + +``` + +**`.gitignore` considerations:** + +```gitignore +# Ignore hook cache and logs +.gemini/hook-cache.json +.gemini/hook-debug.log +.gemini/memory/session-*.jsonl + +# Keep hook scripts +!.gemini/hooks/*.sh +!.gemini/hooks/*.js + +``` + +## Hook security + +### Threat Model + +Understanding where hooks come from and what they can do is critical for secure +usage. + +| Hook Source | Description | +| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| **System** | Configured by system administrators (for example, `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. | +| **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. | +| **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). | +| **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. | + +#### Project Hook Security + +When you open a project with hooks defined in `.gemini/settings.json`: + +1. **Detection**: Gemini CLI detects the hooks. +2. **Identification**: A unique identity is generated for each hook based on its + `name` and `command`. +3. **Warning**: If this specific hook identity has not been seen before, a + **warning** is displayed. +4. **Execution**: The hook is executed (unless specific security settings block + it). +5. **Trust**: The hook is marked as "trusted" for this project. + +> **Modification detection**: If the `command` string of a project hook is +> changed (for example, by a `git pull`), its identity changes. Gemini CLI will +> treat it as a **new, untrusted hook** and warn you again. This prevents +> malicious actors from silently swapping a verified command for a malicious +> one. + +### Risks + +| Risk | Description | +| :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | +| **Arbitrary Code Execution** | Hooks run as your user. They can do anything you can do (delete files, install software). | +| **Data Exfiltration** | A hook could read your input (prompts), output (code), or environment variables (`GEMINI_API_KEY`) and send them to a remote server. | +| **Prompt Injection** | Malicious content in a file or web page could trick an LLM into running a tool that triggers a hook in an unexpected way. | + +### Mitigation Strategies + +#### Verify the source + +**Verify the source** of any project hooks or extensions before enabling them. + +- For open-source projects, a quick review of the hook scripts is recommended. +- For extensions, ensure you trust the author or publisher (for example, + verified publishers, well-known community members). +- Be cautious with obfuscated scripts or compiled binaries from unknown sources. + +#### Sanitize environment + +Hooks inherit the environment of Gemini CLI process, which may include sensitive +API keys. Gemini CLI provides a +[redaction system](../reference/configuration.md#environment-variable-redaction) +that automatically filters variables matching sensitive patterns (for example, +`KEY`, `TOKEN`). + +> **Disabled by Default**: Environment redaction is currently **OFF by +> default**. We strongly recommend enabling it if you are running third-party +> hooks or working in sensitive environments. + +**Impact on hooks:** + +- **Security**: Prevents your hook scripts from accidentally leaking secrets. +- **Troubleshooting**: If your hook depends on a specific environment variable + that is being blocked, you must explicitly allow it in `settings.json`. + +```json +{ + "security": { + "environmentVariableRedaction": { + "enabled": true, + "allowed": ["MY_REQUIRED_TOOL_KEY"] + } + } +} +``` + +**System administrators:** You can enforce redaction for all users in the system +configuration. + +## Troubleshooting + +### Hook not executing + +**Check hook name in `/hooks panel`:** Verify the hook appears in the list and +is enabled. + +**Verify matcher pattern:** + +```bash +# Test regex pattern +echo "write_file|replace" | grep -E "write_.*|replace" + +``` + +**Check disabled list:** Verify the hook is not listed in your `settings.json`: + +```json +{ + "hooks": { + "disabled": ["my-hook-name"] + } +} +``` + +**Ensure script is executable**: For macOS and Linux users, verify the script +has execution permissions: + +```bash +ls -la .gemini/hooks/my-hook.sh +chmod +x .gemini/hooks/my-hook.sh +``` + +**Windows Note**: On Windows, ensure your execution policy allows running +scripts (for example, `Get-ExecutionPolicy`). + +**Verify script path:** Ensure the path in `settings.json` resolves correctly. + +```bash +# Check path expansion +echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" + +# Verify file exists +test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" +``` + +### Hook timing out + +**Check configured timeout:** The default is 60000ms (1 minute). You can +increase this in `settings.json`: + +```json +{ + "name": "slow-hook", + "timeout": 120000 +} +``` + +**Optimize slow operations:** Move heavy processing to background tasks or use +caching. + +### Invalid JSON output + +**Validate JSON before outputting:** + +```bash +#!/usr/bin/env bash +output='{"decision": "allow"}' + +# Validate JSON +if echo "$output" | jq empty 2>/dev/null; then + echo "$output" +else + echo "Invalid JSON generated" >&2 + exit 1 +fi + +``` + +### Environment variables not available + +**Check if variable is set:** + +```bash +#!/usr/bin/env bash +if [ -z "$GEMINI_PROJECT_DIR" ]; then + echo "GEMINI_PROJECT_DIR not set" >&2 + exit 1 +fi + +``` + +**Debug available variables:** + +```bash +env > .gemini/hook-env.log +``` + +## Authoring secure hooks + +When writing your own hooks, follow these practices to ensure they are robust +and secure. + +### Validate all inputs + +Never trust data from hooks without validation. Hook inputs often come from the +LLM or user prompts, which can be manipulated. + +```bash +#!/usr/bin/env bash +input=$(cat) + +# Validate JSON structure +if ! echo "$input" | jq empty 2>/dev/null; then + echo "Invalid JSON input" >&2 + exit 1 +fi + +# Validate tool_name explicitly +tool_name=$(echo "$input" | jq -r '.tool_name // empty') +if [[ "$tool_name" != "write_file" && "$tool_name" != "read_file" ]]; then + echo "Unexpected tool: $tool_name" >&2 + exit 1 +fi +``` + +### Use timeouts + +Prevent denial-of-service (hanging agents) by enforcing timeouts. Gemini CLI +defaults to 60 seconds, but you should set stricter limits for fast hooks. + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "*", + "hooks": [ + { + "name": "fast-validator", + "type": "command", + "command": "./hooks/validate.sh", + "timeout": 5000 // 5 seconds + } + ] + } + ] + } +} +``` + +### Limit permissions + +Run hooks with minimal required permissions: + +```bash +#!/usr/bin/env bash +# Don't run as root +if [ "$EUID" -eq 0 ]; then + echo "Hook should not run as root" >&2 + exit 1 +fi + +# Check file permissions before writing +if [ -w "$file_path" ]; then + # Safe to write +else + echo "Insufficient permissions" >&2 + exit 1 +fi +``` + +### Example: Secret Scanner + +Use `BeforeTool` hooks to prevent committing sensitive data. This is a powerful +pattern for enhancing security in your workflow. + +```javascript +const SECRET_PATTERNS = [ + /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, + /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, + /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, + /AKIA[0-9A-Z]{16}/, // AWS access key + /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token + /sk-[a-zA-Z0-9]{48}/, // OpenAI API key +]; + +function containsSecret(content) { + return SECRET_PATTERNS.some((pattern) => pattern.test(content)); +} +``` + +## Privacy considerations + +Hook inputs and outputs may contain sensitive information. + +### What data is collected + +Hook telemetry may include inputs (prompts, code) and outputs (decisions, +reasons) unless disabled. + +### Privacy settings + +**Disable PII logging:** If you are working with sensitive data, disable prompt +logging in your settings: + +```json +{ + "telemetry": { + "logPrompts": false + } +} +``` + +**Suppress Output:** Individual hooks can request their metadata be hidden from +logs and telemetry by returning `"suppressOutput": true` in their JSON response. + +> **Note** + +> `suppressOutput` only affects background logging. Any `systemMessage` or +> `reason` included in the JSON will still be displayed to the user in the +> terminal. + +### Sensitive data in hooks + +If your hooks process sensitive data: + +1. **Minimize logging:** Don't write sensitive data to log files. +2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing + to stderr. diff --git a/docs/hooks/index.md b/docs/hooks/index.md new file mode 100644 index 0000000000000000000000000000000000000000..0125a28eb23bfc9e087bae1d8d52e76ace2ca9f5 --- /dev/null +++ b/docs/hooks/index.md @@ -0,0 +1,168 @@ +# Gemini CLI hooks + +Hooks are scripts or programs that Gemini CLI executes at specific points in the +agentic loop, allowing you to intercept and customize behavior without modifying +the CLI's source code. + +## What are hooks? + +Hooks run synchronously as part of the agent loopβ€”when a hook event fires, +Gemini CLI waits for all matching hooks to complete before continuing. + +With hooks, you can: + +- **Add context:** Inject relevant information (like git history) before the + model processes a request. +- **Validate actions:** Review tool arguments and block potentially dangerous + operations. +- **Enforce policies:** Implement security scanners and compliance checks. +- **Log interactions:** Track tool usage and model responses for auditing. +- **Optimize behavior:** Dynamically filter available tools or adjust model + parameters. + +### Getting started + +- **[Writing hooks guide](../hooks/writing-hooks.md)**: A tutorial on creating + your first hook with comprehensive examples. +- **[Best practices](../hooks/best-practices.md)**: Guidelines on security, + performance, and debugging. +- **[Hooks reference](../hooks/reference.md)**: The definitive technical + specification of I/O schemas and exit codes. + +## Core concepts + +### Hook events + +Hooks are triggered by specific events in Gemini CLI's lifecycle. + +| Event | When It Fires | Impact | Common Use Cases | +| --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- | +| `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context | +| `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state | +| `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns | +| `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution | +| `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses | +| `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions | +| `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection | +| `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops | +| `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results | +| `PreCompress` | Before context compression | Advisory | Save state, notify user | +| `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging | + +### Global mechanics + +Understanding these core principles is essential for building robust hooks. + +#### Strict JSON requirements (The "Golden Rule") + +Hooks communicate via `stdin` (Input) and `stdout` (Output). + +1. **Silence is Mandatory**: Your script **must not** print any plain text to + `stdout` other than the final JSON object. **Even a single `echo` or `print` + call before the JSON will break parsing.** +2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will + fail. The CLI will default to "Allow" and treat the entire output as a + `systemMessage`. +3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (for + example, `echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts + to parse it as JSON. + +#### Exit codes + +Gemini CLI uses exit codes to determine the high-level outcome of a hook +execution: + +| Exit Code | Label | Behavioral Impact | +| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (for example, `{"decision": "deny"}`). | +| **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. | +| **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. | + +#### Matchers + +You can filter which specific tools or triggers fire your hook using the +`matcher` field. + +- **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular + Expressions**. (for example, `"write_.*"`). +- **Lifecycle events**: Matchers are **Exact Strings**. (for example, + `"startup"`). +- **Wildcards**: `"*"` or `""` (empty string) matches all occurrences. + +## Configuration + +Hooks are configured in `settings.json`. Gemini CLI merges configurations from +multiple layers in the following order of precedence (highest to lowest): + +1. **Project settings**: `.gemini/settings.json` in the current directory. +2. **User settings**: `~/.gemini/settings.json`. +3. **System settings**: `/etc/gemini-cli/settings.json`. +4. **Extensions**: Hooks defined by installed extensions. + +### Configuration schema + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "write_file|replace", + "hooks": [ + { + "name": "security-check", + "type": "command", + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh", + "timeout": 5000 + } + ] + } + ] + } +} +``` + +#### Hook configuration fields + +| Field | Type | Required | Description | +| :------------ | :----- | :-------- | :------------------------------------------------------------------- | +| `type` | string | **Yes** | The execution engine. Currently only `"command"` is supported. | +| `command` | string | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). | +| `name` | string | No | A friendly name for identifying the hook in logs and CLI commands. | +| `timeout` | number | No | Execution timeout in milliseconds (default: 60000). | +| `description` | string | No | A brief explanation of the hook's purpose. | + +--- + +### Environment variables + +Hooks are executed with a sanitized environment. + +- `GEMINI_PROJECT_DIR`: The absolute path to the project root. +- `GEMINI_PLANS_DIR`: The absolute path to the plans directory. +- `GEMINI_SESSION_ID`: The unique ID for the current session. +- `GEMINI_CWD`: The current working directory. +- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. + +## Security and risks + + +> [!WARNING] +> Hooks execute arbitrary code with your user privileges. By +> configuring hooks, you are allowing scripts to run shell commands on your +> machine. + +**Project-level hooks** are particularly risky when opening untrusted projects. +Gemini CLI **fingerprints** project hooks. If a hook's name or command changes +(for example, via `git pull`), it is treated as a **new, untrusted hook** and +you will be warned before it executes. + +See [Security Considerations](../hooks/best-practices.md#using-hooks-securely) +for a detailed threat model. + +## Managing hooks + +Use the CLI commands to manage hooks without editing JSON manually: + +- **View hooks:** `/hooks panel` +- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all` +- **Toggle individual:** `/hooks enable ` or `/hooks disable ` diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md new file mode 100644 index 0000000000000000000000000000000000000000..14846fe2275526bffe20ada016954aed0ae0446f --- /dev/null +++ b/docs/hooks/reference.md @@ -0,0 +1,330 @@ +# Hooks reference + +This document provides the technical specification for Gemini CLI hooks, +including JSON schemas and API details. + +## Global hook mechanics + +- **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and + `stderr` for logs and feedback. +- **Exit codes**: + - `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.** + - `2`: System Block. The action is blocked; `stderr` is used as the rejection + reason. + - `Other`: Warning. A non-fatal failure occurred; the CLI continues with a + warning. +- **Silence is Mandatory**: Your script **must not** print any plain text to + `stdout` other than the final JSON. + +--- + +## Configuration schema + +Hooks are defined in `settings.json` within the `hooks` object. Each event (for +example, `BeforeTool`) contains an array of **hook definitions**. + +### Hook definition + +| Field | Type | Required | Description | +| :----------- | :-------- | :------- | :-------------------------------------------------------------------------------------- | +| `matcher` | `string` | No | A regex (for tools) or exact string (for lifecycle) to filter when the hook runs. | +| `sequential` | `boolean` | No | If `true`, hooks in this group run one after another. If `false`, they run in parallel. | +| `hooks` | `array` | **Yes** | An array of **hook configurations**. | + +### Hook configuration + +| Field | Type | Required | Description | +| :------------ | :------- | :-------- | :------------------------------------------------------------------- | +| `type` | `string` | **Yes** | The execution engine. Currently only `"command"` is supported. | +| `command` | `string` | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). | +| `name` | `string` | No | A friendly name for identifying the hook in logs and CLI commands. | +| `timeout` | `number` | No | Execution timeout in milliseconds (default: 60000). | +| `description` | `string` | No | A brief explanation of the hook's purpose. | + +--- + +## Base input schema + +All hooks receive these common fields via `stdin`: + +```typescript +{ + "session_id": string, // Unique ID for the current session + "transcript_path": string, // Absolute path to session transcript JSON + "cwd": string, // Current working directory + "hook_event_name": string, // The firing event (for example "BeforeTool") + "timestamp": string // ISO 8601 execution time +} +``` + +--- + +## Common output fields + +Most hooks support these fields in their `stdout` JSON: + +| Field | Type | Description | +| :--------------- | :-------- | :----------------------------------------------------------------------------- | +| `systemMessage` | `string` | Displayed immediately to the user in the terminal. | +| `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. | +| `continue` | `boolean` | If `false`, stops the entire agent loop immediately. | +| `stopReason` | `string` | Displayed to the user when `continue` is `false`. | +| `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. | +| `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. | + +--- + +## Tool hooks + +### Matchers and tool names + +For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is +compared against the name of the tool being executed. + +- **Built-in Tools**: You can match any built-in tool (for example, `read_file`, + `run_shell_command`). See the [Tools Reference](../reference/tools) for a full + list of available tool names. +- **MCP Tools**: Tools from MCP servers follow the naming pattern + `mcp__`. +- **Regex Support**: Matchers support regular expressions (for example, + `matcher: "read_.*"` matches all file reading tools). + +### `BeforeTool` + +Fires before a tool is invoked. Used for argument validation, security checks, +and parameter rewriting. + +- **Input Fields**: + - `tool_name`: (`string`) The name of the tool being called. + - `tool_input`: (`object`) The raw arguments generated by the model. + - `mcp_context`: (`object`) Optional metadata for MCP-based tools. + - `original_request_name`: (`string`) The original name of the tool being + called, if this is a tail tool call. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from + executing. + - `reason`: Required if denied. This text is sent **to the agent** as a tool + error, allowing it to respond or retry. + - `hookSpecificOutput.tool_input`: An object that **merges with and + overrides** the model's arguments before execution. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the + `reason` sent to the agent. **The turn continues.** + +### `AfterTool` + +Fires after a tool executes. Used for result auditing, context injection, or +hiding sensitive output from the agent. + +- **Input Fields**: + - `tool_name`: (`string`) + - `tool_input`: (`object`) The original arguments. + - `tool_response`: (`object`) The result containing `llmContent`, + `returnDisplay`, and optional `error`. + - `mcp_context`: (`object`) + - `original_request_name`: (`string`) The original name of the tool being + called, if this is a tail tool call. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to hide the real tool output from the agent. + - `reason`: Required if denied. This text **replaces** the tool result sent + back to the model. + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + tool result for the agent. + - `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`) + A request to execute another tool immediately after this one. The result of + this "tail call" will replace the original tool's response. Ideal for + programmatic tool routing. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the + replacement content sent to the agent. **The turn continues.** + +--- + +## Agent hooks + +### `BeforeAgent` + +Fires after a user submits a prompt, but before the agent begins planning. Used +for prompt validation or injecting dynamic context. + +- **Input Fields**: + - `prompt`: (`string`) The original text submitted by the user. +- **Relevant Output Fields**: + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + prompt for this turn only. + - `decision`: Set to `"deny"` to block the turn and **discard the user's + message** (it will not appear in history). + - `continue`: Set to `false` to block the turn but **save the message to + history**. + - `reason`: Required if denied or stopped. +- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from + context. Same as `decision: "deny"`. + +### `AfterAgent` + +Fires once per turn after the model generates its final response. Primary use +case is response validation and automatic retries. + +- **Input Fields**: + - `prompt`: (`string`) The user's original request. + - `prompt_response`: (`string`) The final text generated by the agent. + - `stop_hook_active`: (`boolean`) Indicates if this hook is already running as + part of a retry sequence. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to **reject the response** and force a retry. + - `reason`: Required if denied. This text is sent **to the agent as a new + prompt** to request a correction. + - `continue`: Set to `false` to **stop the session** without retrying. + - `hookSpecificOutput.clearContext`: If `true`, clears conversation history + (LLM memory) while preserving UI display. +- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry + turn using `stderr` as the feedback prompt. + +--- + +## Model hooks + +### `BeforeModel` + +Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic +request format. + +- **Input Fields**: + - `llm_request`: (`object`) Contains `model`, `messages`, and `config` + (generation params). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_request`: An object that **overrides** parts of the + outgoing request (for example, changing models or temperature). + - `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If + provided, the CLI skips the LLM call entirely and uses this as the response. + - `decision`: Set to `"deny"` to block the request and abort the turn. +- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses + `stderr` as the error message. + +### `BeforeToolSelection` + +Fires before the LLM decides which tools to call. Used to filter the available +toolset or force specific tool modes. + +- **Input Fields**: + - `llm_request`: (`object`) Same format as `BeforeModel`. +- **Relevant Output Fields**: + - `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`) + - `"NONE"`: Disables all tools (Wins over other hooks). + - `"ANY"`: Forces at least one tool call. + - `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist + of tool names. +- **Union Strategy**: Multiple hooks' whitelists are **combined**. +- **Limitations**: Does **not** support `decision`, `continue`, or + `systemMessage`. + +### `AfterModel` + +Fires immediately after an LLM response chunk is received. Used for real-time +redaction or PII filtering. + +- **Input Fields**: + - `llm_request`: (`object`) The original request. + - `llm_response`: (`object`) The model's response (or a single chunk during + streaming). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_response`: An object that **replaces** the model's + response chunk. + - `decision`: Set to `"deny"` to discard the response chunk and block the + turn. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Note on Streaming**: Fired for **every chunk** generated by the model. + Modifying the response only affects the current chunk. +- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's + output. Uses `stderr` as the error message. + +--- + +## Lifecycle & system hooks + +### `SessionStart` + +Fires on application startup, resuming a session, or after a `/clear` command. +Used for loading initial context. + +- **Input fields**: + - `source`: (`"startup" | "resume" | "clear"`) +- **Relevant output fields**: + - `hookSpecificOutput.additionalContext`: (`string`) + - **Interactive**: Injected as the first turn in history. + - **Non-interactive**: Prepended to the user's prompt. + - `systemMessage`: Shown at the start of the session. +- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup + is never blocked. + +### `SessionEnd` + +Fires when the CLI exits or a session is cleared. Used for cleanup or final +telemetry. + +- **Input Fields**: + - `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user during shutdown. +- **Best Effort**: The CLI **will not wait** for this hook to complete and + ignores all flow-control fields (`continue`, `decision`). + +### `Notification` + +Fires when the CLI emits a system alert (for example, Tool Permissions). Used +for external logging or cross-platform alerts. + +- **Input Fields**: + - `notification_type`: (`"ToolPermission"`) + - `message`: Summary of the alert. + - `details`: JSON object with alert-specific metadata (for example, tool name, + file path). +- **Relevant Output Fields**: + - `systemMessage`: Displayed alongside the system alert. +- **Observability Only**: This hook **cannot** block alerts or grant permissions + automatically. Flow-control fields are ignored. + +### `PreCompress` + +Fires before the CLI summarizes history to save tokens. Used for logging or +state saving. + +- **Input Fields**: + - `trigger`: (`"auto" | "manual"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user before compression. +- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the + compression process. Flow-control fields are ignored. + +--- + +## Stable Model API + +Gemini CLI uses these structures to ensure hooks don't break across SDK updates. + +**LLMRequest**: + +```typescript +{ + "model": string, + "messages": Array<{ + "role": "user" | "model" | "system", + "content": string // Non-text parts are filtered out for hooks + }>, + "config": { "temperature": number, ... }, + "toolConfig": { "mode": string, "allowedFunctionNames": string[] } +} + +``` + +**LLMResponse**: + +```typescript +{ + "candidates": Array<{ + "content": { "role": "model", "parts": string[] }, + "finishReason": string + }>, + "usageMetadata": { "totalTokenCount": number } +} +``` diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md new file mode 100644 index 0000000000000000000000000000000000000000..f4f156776f3b01e1facfd7b6b54d1da7b3f13e9a --- /dev/null +++ b/docs/hooks/writing-hooks.md @@ -0,0 +1,474 @@ +# Writing hooks for Gemini CLI + +This guide will walk you through creating hooks for Gemini CLI, from a simple +logging hook to a comprehensive workflow assistant. + +## Prerequisites + +Before you start, make sure you have: + +- Gemini CLI installed and configured +- Basic understanding of shell scripting or JavaScript/Node.js +- Familiarity with JSON for hook input/output + +## Quick start + +Let's create a simple hook that logs all tool executions to understand the +basics. + +**Crucial Rule:** Always write logs to `stderr`. Write only the final JSON to +`stdout`. + +### Step 1: Create your hook script + +Create a directory for hooks and a simple logging script. + +> **Note**: +> +> This example uses `jq` to parse JSON. If you don't have it installed, you can +> perform similar logic using Node.js or Python. + +**macOS/Linux** + +```bash +mkdir -p .gemini/hooks +cat > .gemini/hooks/log-tools.sh << 'EOF' +#!/usr/bin/env bash +# Read hook input from stdin +input=$(cat) + +# Extract tool name (requires jq) +tool_name=$(echo "$input" | jq -r '.tool_name') + +# Log to stderr (visible in terminal if hook fails, or captured in logs) +echo "Logging tool: $tool_name" >&2 + +# Log to file +echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt + +# Return success (exit 0) with empty JSON +echo "{}" +exit 0 +EOF + +chmod +x .gemini/hooks/log-tools.sh +``` + +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path ".gemini\hooks" +@" +# Read hook input from stdin +`$inputJson = `$input | Out-String | ConvertFrom-Json + +# Extract tool name +`$toolName = `$inputJson.tool_name + +# Log to stderr (visible in terminal if hook fails, or captured in logs) +[Console]::Error.WriteLine("Logging tool: `$toolName") + +# Log to file +"[`$(Get-Date -Format 'o')] Tool executed: `$toolName" | Out-File -FilePath ".gemini\tool-log.txt" -Append -Encoding utf8 + +# Return success with empty JSON +"{}" +"@ | Out-File -FilePath ".gemini\hooks\log-tools.ps1" -Encoding utf8 +``` + +## Exit Code Strategies + +There are two ways to control or block an action in Gemini CLI: + +| Strategy | Exit Code | Implementation | Best For | +| :------------------------- | :-------- | :----------------------------------------------------------------- | :---------------------------------------------------------- | +| **Structured (Idiomatic)** | `0` | Return a JSON object like `{"decision": "deny", "reason": "..."}`. | Production hooks, custom user feedback, and complex logic. | +| **Emergency Brake** | `2` | Print the error message to `stderr` and exit. | Simple security gates, script errors, or rapid prototyping. | + +## Practical examples + +### Security: Block secrets in commits + +Prevent committing files containing API keys or passwords. Note that we use +**Exit Code 0** to provide a structured denial message to the agent. + +**`.gemini/hooks/block-secrets.sh`:** + +```bash +#!/usr/bin/env bash +input=$(cat) + +# Extract content being written +content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""') + +# Check for secrets +if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then + # Log to stderr + echo "Blocked potential secret" >&2 + + # Return structured denial to stdout + cat </dev/null || echo "No git history") + +# Return as JSON +cat < m.role === 'user'); + + if (!lastUserMessage) { + console.log(JSON.stringify({})); // Do nothing + return; + } + + const text = lastUserMessage.content; + const allowed = ['write_todos']; // Always allow memory + + // Simple keyword matching + if (text.includes('read') || text.includes('check')) { + allowed.push('read_file', 'list_directory'); + } + if (text.includes('test')) { + allowed.push('run_shell_command'); + } + + // If we found specific intent, filter tools. Otherwise allow all. + if (allowed.length > 1) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'ANY', // Force usage of one of these tools (or AUTO) + allowedFunctionNames: allowed, + }, + }, + }), + ); + } else { + console.log(JSON.stringify({})); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**`.gemini/settings.json`:** + +```json +{ + "hooks": { + "BeforeToolSelection": [ + { + "matcher": "*", + "hooks": [ + { + "name": "intent-filter", + "type": "command", + "command": "node .gemini/hooks/filter-tools.js" + } + ] + } + ] + } +} +``` + +> **TIP** +> +> **Union Aggregation Strategy**: `BeforeToolSelection` is unique in that it +> combines the results of all matching hooks. If you have multiple filtering +> hooks, the agent will receive the **union** of all whitelisted tools. Only +> using `mode: "NONE"` will override other hooks to disable all tools. + +## Complete example: Smart Development Workflow Assistant + +This comprehensive example demonstrates all hook events working together. We +will build a system that maintains memory, filters tools, and checks for +security. + +### Architecture + +1. **SessionStart**: Load project memories. +2. **BeforeAgent**: Inject memories into context. +3. **BeforeToolSelection**: Filter tools based on intent. +4. **BeforeTool**: Scan for secrets. +5. **AfterModel**: Record interactions. +6. **AfterAgent**: Validate final response quality (Retry). +7. **SessionEnd**: Consolidate memories. + +### Configuration (`.gemini/settings.json`) + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "name": "init", + "type": "command", + "command": "node .gemini/hooks/init.js" + } + ] + } + ], + "BeforeAgent": [ + { + "matcher": "*", + "hooks": [ + { + "name": "memory", + "type": "command", + "command": "node .gemini/hooks/inject-memories.js" + } + ] + } + ], + "BeforeToolSelection": [ + { + "matcher": "*", + "hooks": [ + { + "name": "filter", + "type": "command", + "command": "node .gemini/hooks/rag-filter.js" + } + ] + } + ], + "BeforeTool": [ + { + "matcher": "write_file", + "hooks": [ + { + "name": "security", + "type": "command", + "command": "node .gemini/hooks/security.js" + } + ] + } + ], + "AfterModel": [ + { + "matcher": "*", + "hooks": [ + { + "name": "record", + "type": "command", + "command": "node .gemini/hooks/record.js" + } + ] + } + ], + "AfterAgent": [ + { + "matcher": "*", + "hooks": [ + { + "name": "validate", + "type": "command", + "command": "node .gemini/hooks/validate.js" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "exit", + "hooks": [ + { + "name": "save", + "type": "command", + "command": "node .gemini/hooks/consolidate.js" + } + ] + } + ] + } +} +``` + +### Hook Scripts + +> **Note**: For brevity, these scripts use `console.error` for logging and +> standard `console.log` for JSON output. + +#### 1. Initialize (`init.js`) + +```javascript +#!/usr/bin/env node +// Initialize DB or resources +console.error('Initializing assistant...'); + +// Output to user +console.log( + JSON.stringify({ + systemMessage: '🧠 Smart Assistant Loaded', + }), +); +``` + +#### 2. Inject Memories (`inject-memories.js`) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); + +async function main() { + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + // Assume we fetch memories from a DB here + const memories = '- [Memory] Always use TypeScript for this project.'; + + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'BeforeAgent', + additionalContext: `\n## Relevant Memories\n${memories}`, + }, + }), + ); +} +main(); +``` + +#### 3. Security Check (`security.js`) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const input = JSON.parse(fs.readFileSync(0)); +const content = input.tool_input.content || ''; + +if (content.includes('SECRET_KEY')) { + console.log( + JSON.stringify({ + decision: 'deny', + reason: 'Found SECRET_KEY in content', + systemMessage: '🚨 Blocked sensitive commit', + }), + ); + process.exit(0); +} + +console.log(JSON.stringify({ decision: 'allow' })); +``` + +#### 4. Record Interaction (`record.js`) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const input = JSON.parse(fs.readFileSync(0)); +const { llm_request, llm_response } = input; +const logFile = path.join( + process.env.GEMINI_PROJECT_DIR, + '.gemini/memory/session.jsonl', +); + +fs.appendFileSync( + logFile, + JSON.stringify({ + request: llm_request, + response: llm_response, + timestamp: new Date().toISOString(), + }) + '\n', +); + +console.log(JSON.stringify({})); +``` + +#### 5. Validate Response (`validate.js`) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const input = JSON.parse(fs.readFileSync(0)); +const response = input.prompt_response; + +// Example: Check if the agent forgot to include a summary +if (!response.includes('Summary:')) { + console.log( + JSON.stringify({ + decision: 'block', // Triggers an automatic retry turn + reason: 'Your response is missing a Summary section. Please add one.', + systemMessage: 'πŸ”„ Requesting missing summary...', + }), + ); + process.exit(0); +} + +console.log(JSON.stringify({ decision: 'allow' })); +``` + +#### 6. Consolidate Memories (`consolidate.js`) + +```javascript +#!/usr/bin/env node +// Logic to save final session state +console.error('Consolidating memories for session end...'); +``` + +## Packaging as an extension + +While project-level hooks are great for specific repositories, you can share +your hooks across multiple projects by packaging them as a +[Gemini CLI extension](../extensions/index.md). This provides version control, +easy distribution, and centralized management. diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md new file mode 100644 index 0000000000000000000000000000000000000000..e03e167d42e8e70424fe0d2198d802904bf5d357 --- /dev/null +++ b/docs/ide-integration/ide-companion-spec.md @@ -0,0 +1,271 @@ +# Gemini CLI companion plugin: Interface specification + +> Last Updated: September 15, 2025 + +This document defines the contract for building a companion plugin to enable +Gemini CLI's IDE mode. For VS Code, these features (native diffing, context +awareness) are provided by the official extension +([marketplace](https://marketplace.visualstudio.com/items?itemName=Google.gemini-cli-vscode-ide-companion)). +This specification is for contributors who wish to bring similar functionality +to other editors like JetBrains IDEs, Sublime Text, etc. + +## I. The communication interface + +Gemini CLI and the IDE plugin communicate through a local communication channel. + +### 1. Transport layer: MCP over HTTP + +The plugin **MUST** run a local HTTP server that implements the **Model Context +Protocol (MCP)**. + +- **Protocol:** The server must be a valid MCP server. We recommend using an + existing MCP SDK for your language of choice if available. +- **Endpoint:** The server should expose a single endpoint (for example, `/mcp`) + for all MCP communication. +- **Port:** The server **MUST** listen on a dynamically assigned port (that is, + listen on port `0`). + +### 2. Discovery mechanism: The port file + +For Gemini CLI to connect, it needs to discover which IDE instance it's running +in and what port your server is using. The plugin **MUST** facilitate this by +creating a "discovery file." + +- **How the CLI finds the file:** The CLI determines the Process ID (PID) of the + IDE it's running in by traversing the process tree. It then looks for a + discovery file that contains this PID in its name. +- **File location:** The file must be created in a specific directory: + `os.tmpdir()/gemini/ide/`. Your plugin must create this directory if it + doesn't exist. +- **File naming convention:** The filename is critical and **MUST** follow the + pattern: `gemini-ide-server-${PID}-${PORT}.json` + - `${PID}`: The process ID of the parent IDE process. Your plugin must + determine this PID and include it in the filename. + - `${PORT}`: The port your MCP server is listening on. +- **File content and workspace validation:** The file **MUST** contain a JSON + object with the following structure: + + ```json + { + "port": 12345, + "workspacePath": "/path/to/project1:/path/to/project2", + "authToken": "a-very-secret-token", + "ideInfo": { + "name": "vscode", + "displayName": "VS Code" + } + } + ``` + + - `port` (number, required): The port of the MCP server. + - `workspacePath` (string, required): A list of all open workspace root paths, + delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for + Windows). The CLI uses this path to ensure it's running in the same project + folder that's open in the IDE. If the CLI's current working directory is not + a sub-directory of `workspacePath`, the connection will be rejected. Your + plugin **MUST** provide the correct, absolute path(s) to the root of the + open workspace(s). + - `authToken` (string, required): A secret token for securing the connection. + The CLI will include this token in an `Authorization: Bearer ` header + on all requests. + - `ideInfo` (object, required): Information about the IDE. + - `name` (string, required): A short, lowercase identifier for the IDE (for + example, `vscode`, `jetbrains`). + - `displayName` (string, required): A user-friendly name for the IDE (for + example, `VS Code`, `JetBrains IDE`). + +- **Authentication:** To secure the connection, the plugin **MUST** generate a + unique, secret token and include it in the discovery file. The CLI will then + include this token in the `Authorization` header for all requests to the MCP + server (for example, `Authorization: Bearer a-very-secret-token`). Your server + **MUST** validate this token on every request and reject any that are + unauthorized. +- **Tie-breaking with environment variables (recommended):** For the most + reliable experience, your plugin **SHOULD** both create the discovery file and + set the `GEMINI_CLI_IDE_SERVER_PORT` environment variable in the integrated + terminal. The file serves as the primary discovery mechanism, but the + environment variable is crucial for tie-breaking. If a user has multiple IDE + windows open for the same workspace, the CLI uses the + `GEMINI_CLI_IDE_SERVER_PORT` variable to identify and connect to the correct + window's server. + +## II. The context interface + +To enable context awareness, the plugin **MAY** provide the CLI with real-time +information about the user's activity in the IDE. + +### `ide/contextUpdate` notification + +The plugin **MAY** send an `ide/contextUpdate` +[notification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#notifications) +to the CLI whenever the user's context changes. + +- **Triggering events:** This notification should be sent (with a recommended + debounce of 50ms) when: + - A file is opened, closed, or focused. + - The user's cursor position or text selection changes in the active file. +- **Payload (`IdeContext`):** The notification parameters **MUST** be an + `IdeContext` object: + + ```typescript + interface IdeContext { + workspaceState?: { + openFiles?: File[]; + isTrusted?: boolean; + }; + } + + interface File { + // Absolute path to the file + path: string; + // Last focused Unix timestamp (for ordering) + timestamp: number; + // True if this is the currently focused file + isActive?: boolean; + cursor?: { + // 1-based line number + line: number; + // 1-based character number + character: number; + }; + // The text currently selected by the user + selectedText?: string; + } + ``` + + +> [!NOTE] +> The `openFiles` list should only include files that exist on disk. +> Virtual files (for example, unsaved files without a path, editor settings pages) +> **MUST** be excluded. + +### How the CLI uses this context + +After receiving the `IdeContext` object, the CLI performs several normalization +and truncation steps before sending the information to the model. + +- **File ordering:** The CLI uses the `timestamp` field to determine the most + recently used files. It sorts the `openFiles` list based on this value. + Therefore, your plugin **MUST** provide an accurate Unix timestamp for when a + file was last focused. +- **Active file:** The CLI considers only the most recent file (after sorting) + to be the "active" file. It will ignore the `isActive` flag on all other files + and clear their `cursor` and `selectedText` fields. Your plugin should focus + on setting `isActive: true` and providing cursor/selection details only for + the currently focused file. +- **Truncation:** To manage token limits, the CLI truncates both the file list + (to 10 files) and the `selectedText` (to 16KB). + +While the CLI handles the final truncation, it is highly recommended that your +plugin also limits the amount of context it sends. + +## III. The diffing interface + +To enable interactive code modifications, the plugin **MAY** expose a diffing +interface. This allows the CLI to request that the IDE open a diff view, showing +proposed changes to a file. The user can then review, edit, and ultimately +accept or reject these changes directly within the IDE. + +### `openDiff` tool + +The plugin **MUST** register an `openDiff` tool on its MCP server. + +- **Description:** This tool instructs the IDE to open a modifiable diff view + for a specific file. +- **Request (`OpenDiffRequest`):** The tool is invoked via a `tools/call` + request. The `arguments` field within the request's `params` **MUST** be an + `OpenDiffRequest` object. + + ```typescript + interface OpenDiffRequest { + // The absolute path to the file to be diffed. + filePath: string; + // The proposed new content for the file. + newContent: string; + } + ``` + +- **Response (`CallToolResult`):** The tool **MUST** immediately return a + `CallToolResult` to acknowledge the request and report whether the diff view + was successfully opened. + + - On Success: If the diff view was opened successfully, the response **MUST** + contain empty content (that is, `content: []`). + - On Failure: If an error prevented the diff view from opening, the response + **MUST** have `isError: true` and include a `TextContent` block in the + `content` array describing the error. + + The actual outcome of the diff (acceptance or rejection) is communicated + asynchronously via notifications. + +### `closeDiff` tool + +The plugin **MUST** register a `closeDiff` tool on its MCP server. + +- **Description:** This tool instructs the IDE to close an open diff view for a + specific file. +- **Request (`CloseDiffRequest`):** The tool is invoked via a `tools/call` + request. The `arguments` field within the request's `params` **MUST** be an + `CloseDiffRequest` object. + + ```typescript + interface CloseDiffRequest { + // The absolute path to the file whose diff view should be closed. + filePath: string; + } + ``` + +- **Response (`CallToolResult`):** The tool **MUST** return a `CallToolResult`. + - On Success: If the diff view was closed successfully, the response **MUST** + include a single **TextContent** block in the content array containing the + file's final content before closing. + - On Failure: If an error prevented the diff view from closing, the response + **MUST** have `isError: true` and include a `TextContent` block in the + `content` array describing the error. + +### `ide/diffAccepted` notification + +When the user accepts the changes in a diff view (for example, by clicking an +"Apply" or "Save" button), the plugin **MUST** send an `ide/diffAccepted` +notification to the CLI. + +- **Payload:** The notification parameters **MUST** include the file path and + the final content of the file. The content may differ from the original + `newContent` if the user made manual edits in the diff view. + + ```typescript + { + // The absolute path to the file that was diffed. + filePath: string; + // The full content of the file after acceptance. + content: string; + } + ``` + +### `ide/diffRejected` notification + +When the user rejects the changes (for example, by closing the diff view without +accepting), the plugin **MUST** send an `ide/diffRejected` notification to the +CLI. + +- **Payload:** The notification parameters **MUST** include the file path of the + rejected diff. + + ```typescript + { + // The absolute path to the file that was diffed. + filePath: string; + } + ``` + +## IV. The lifecycle interface + +The plugin **MUST** manage its resources and the discovery file correctly based +on the IDE's lifecycle. + +- **On activation (IDE startup/plugin enabled):** + 1. Start the MCP server. + 2. Create the discovery file. +- **On deactivation (IDE shutdown/plugin disabled):** + 1. Stop the MCP server. + 2. Delete the discovery file. diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md new file mode 100644 index 0000000000000000000000000000000000000000..428fe5580822bf7cb91356e0f8ff6f086bdb2a08 --- /dev/null +++ b/docs/ide-integration/index.md @@ -0,0 +1,307 @@ +# IDE Integration + +Gemini CLI can integrate with your IDE to provide a more seamless and +context-aware experience. This integration allows the CLI to understand your +workspace better and enables powerful features like native in-editor diffing. + +There are two primary ways to integrate Gemini CLI with an IDE: + +1. **VS Code companion extension**: Install the "Gemini CLI Companion" + extension on [Antigravity](https://antigravity.google), + [Visual Studio Code](https://code.visualstudio.com/), or other VS Code + compatible editors. +2. **Agent Client Protocol (ACP)**: An open protocol for interoperability + between AI coding agents and IDEs. This method is used for integrations with + tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy + discovery and installation of compatible agents like Gemini CLI. + +## VS Code companion extension + +The **Gemini CLI Companion extension** grants Gemini CLI direct access to your +VS Code compatible IDEs and improves your experience by providing real-time +context such as open files, cursor positions, and text selection. The extension +also enables a native diffing interface so you can seamlessly review and apply +AI-generated code changes directly within your editor. + +### Features + +- **Workspace context:** The CLI automatically gains awareness of your workspace + to provide more relevant and accurate responses. This context includes: + + - The **10 most recently accessed files** in your workspace. + - Your active cursor position. + - Any text you have selected (up to a 16KB limit; longer selections will be + truncated). + +- **Native diffing:** When Gemini suggests code modifications, you can view the + changes directly within your IDE's native diff viewer. This lets you review, + edit, and accept or reject the suggested changes seamlessly. + +- **VS Code commands:** You can access Gemini CLI features directly from the VS + Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`): + - `Gemini CLI: Run`: Starts a new Gemini CLI session in the integrated + terminal. + - `Gemini CLI: Accept Diff`: Accepts the changes in the active diff editor. + - `Gemini CLI: Close Diff Editor`: Rejects the changes and closes the active + diff editor. + - `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for + the extension. + +### Installation and setup + +There are three ways to set up the IDE integration: + +#### 1. Automatic nudge (recommended) + +When you run Gemini CLI inside a supported editor, it will automatically detect +your environment and prompt you to connect. Answering "Yes" will automatically +run the necessary setup, which includes installing the companion extension and +enabling the connection. + +#### 2. Manual installation from CLI + +If you previously dismissed the prompt or want to install the extension +manually, you can run the following command inside Gemini CLI: + +``` +/ide install +``` + +This will find the correct extension for your IDE and install it. + +#### 3. Manual installation from a marketplace + +You can also install the extension directly from a marketplace. + +- **For Visual Studio Code:** Install from the + [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=google.gemini-cli-vscode-ide-companion). +- **For VS Code forks:** To support forks of VS Code, the extension is also + published on the + [Open VSX Registry](https://open-vsx.org/extension/google/gemini-cli-vscode-ide-companion). + Follow your editor's instructions for installing extensions from this + registry. + + +> [!NOTE] +> The "Gemini CLI Companion" extension may appear towards the bottom of +> search results. If you don't see it immediately, try scrolling down or +> sorting by "Newly Published". +> +> After manually installing the extension, you must run `/ide enable` in the CLI +> to activate the integration. + +### Usage + +#### Enabling and disabling + +You can control the IDE integration from within the CLI: + +- To enable the connection to the IDE, run: + ``` + /ide enable + ``` +- To disable the connection, run: + ``` + /ide disable + ``` + +When enabled, Gemini CLI will automatically attempt to connect to the IDE +companion extension. + +#### Checking the status + +To check the connection status and see the context the CLI has received from the +IDE, run: + +``` +/ide status +``` + +If connected, this command will show the IDE it's connected to and a list of +recently opened files it is aware of. + + +> [!NOTE] +> The file list is limited to 10 recently accessed files within your +> workspace and only includes local files on disk. + +#### Working with diffs + +When you ask Gemini to modify a file, it can open a diff view directly in your +editor. + +**To accept a diff**, you can perform any of the following actions: + +- Click the **checkmark icon** in the diff editor's title bar. +- Save the file (for example, with `Cmd+S` or `Ctrl+S`). +- Open the Command Palette and run **Gemini CLI: Accept Diff**. +- Respond with `yes` in the CLI when prompted. + +**To reject a diff**, you can: + +- Click the **'x' icon** in the diff editor's title bar. +- Close the diff editor tab. +- Open the Command Palette and run **Gemini CLI: Close Diff Editor**. +- Respond with `no` in the CLI when prompted. + +You can also **modify the suggested changes** directly in the diff view before +accepting them. + +If you select β€˜Allow for this session’ in the CLI, changes will no longer show +up in the IDE as they will be auto-accepted. + +## Agent Client Protocol (ACP) + +ACP is an open protocol that standardizes how AI coding agents communicate with +code editors and IDEs. It addresses the challenge of fragmented distribution, +where agents traditionally needed custom integrations for each client. With ACP, +developers can implement their agent once, and it becomes compatible with any +ACP-compliant editor. + +For a comprehensive introduction to ACP, including its architecture and +benefits, refer to the official +[ACP Introduction](https://agentclientprotocol.com/get-started/introduction) +documentation. + +### The ACP Agent Registry + +Gemini CLI is officially available in the **ACP Agent Registry**. This allows +you to install and update Gemini CLI directly within supporting IDEs and +eliminates the need for manual downloads or IDE-specific extensions. + +Using the registry ensures: + +- **Ease of use**: Discover and install agents directly within your IDE + settings. +- **Latest versions**: Ensures users always have access to the most up-to-date + agent implementations. + +For more details on how the registry works, visit the official +[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page. +You can learn about how specific IDEs leverage this integration in the following +section. + +### IDE-specific integration + +Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry. +Here’s how different IDEs leverage the ACP and the registry: + +#### JetBrains IDEs + +JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry +support, allowing users to find and install ACP-compatible agents directly. + +For more details, refer to the official +[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/). + +#### Zed + +Zed, a modern code editor, also integrates with the ACP Agent Registry. This +allows Zed users to easily browse, install, and manage ACP agents. + +Learn more about Zed's integration with the ACP Registry in their +[blog post](https://zed.dev/blog/acp-registry). + +#### Other ACP-compatible IDEs + +Any other IDE that supports the ACP Agent Registry can install Gemini CLI +directly through their in-built registry features. + +## Using with sandboxing + +If you are using Gemini CLI within a sandbox, be aware of the following: + +- **On macOS:** The IDE integration requires network access to communicate with + the IDE companion extension. You must use a Seatbelt profile that allows + network access. +- **In a Docker container:** If you run Gemini CLI inside a Docker (or Podman) + container, the IDE integration can still connect to the VS Code extension + running on your host machine. The CLI is configured to automatically find the + IDE server on `host.docker.internal`. No special configuration is usually + required, but you may need to ensure your Docker networking setup allows + connections from the container to the host. + +## Troubleshooting + +### VS Code companion extension errors + +#### Connection errors + +- **Message:** + `πŸ”΄ Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.` + + - **Cause:** Gemini CLI could not find the necessary environment variables + (`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect + to the IDE. This usually means the IDE companion extension is not running or + did not initialize correctly. + - **Solution:** + 1. Make sure you have installed the **Gemini CLI Companion** extension in + your IDE and that it is enabled. + 2. Open a new terminal window in your IDE to ensure it picks up the correct + environment. + +- **Message:** + `πŸ”΄ Disconnected: IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable` + - **Cause:** The connection to the IDE companion was lost. + - **Solution:** Run `/ide enable` to try and reconnect. If the issue + continues, open a new terminal window or restart your IDE. + +#### Manual PID override + +If automatic IDE detection fails, or if you are running Gemini CLI in a +standalone terminal and want to manually associate it with a specific IDE +instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the +process ID (PID) of your IDE. + +**macOS/Linux** + +```bash +export GEMINI_CLI_IDE_PID=12345 +``` + +**Windows (PowerShell)** + +```powershell +$env:GEMINI_CLI_IDE_PID=12345 +``` + +When this variable is set, Gemini CLI will skip automatic detection and attempt +to connect using the provided PID. + +#### Configuration errors + +- **Message:** + `πŸ”΄ Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]` + + - **Cause:** The CLI's current working directory is outside the workspace you + have open in your IDE. + - **Solution:** `cd` into the same directory that is open in your IDE and + restart the CLI. + +- **Message:** + `πŸ”΄ Disconnected: To use this feature, please open a workspace folder in [IDE Name] and try again.` + - **Cause:** You have no workspace open in your IDE. + - **Solution:** Open a workspace in your IDE and restart the CLI. + +#### General errors + +- **Message:** + `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]` + + - **Cause:** You are running Gemini CLI in a terminal or environment that is + not a supported IDE. + - **Solution:** Run Gemini CLI from the integrated terminal of a supported + IDE, like Antigravity or VS Code. + +- **Message:** + `No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.` + - **Cause:** You ran `/ide install`, but the CLI does not have an automated + installer for your specific IDE. + - **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI + Companion", and + [install it manually](#3-manual-installation-from-a-marketplace). + +### ACP integration errors + +For issues related to ACP integration, refer to the debugging and telemetry +section in the [ACP Mode](../cli/acp-mode.md) documentation. diff --git a/docs/mermaid/context.mmd b/docs/mermaid/context.mmd new file mode 100644 index 0000000000000000000000000000000000000000..ebe4fbee1101853a7a9f565c86b532467955e085 --- /dev/null +++ b/docs/mermaid/context.mmd @@ -0,0 +1,103 @@ +graph LR + %% --- Style Definitions --- + classDef new fill:#98fb98,color:#000 + classDef changed fill:#add8e6,color:#000 + classDef unchanged fill:#f0f0f0,color:#000 + + %% --- Subgraphs --- + subgraph "Context Providers" + direction TB + A["gemini.tsx"] + B["AppContainer.tsx"] + end + + subgraph "Contexts" + direction TB + CtxSession["SessionContext"] + CtxVim["VimModeContext"] + CtxSettings["SettingsContext"] + CtxApp["AppContext"] + CtxConfig["ConfigContext"] + CtxUIState["UIStateContext"] + CtxUIActions["UIActionsContext"] + end + + subgraph "Component Consumers" + direction TB + ConsumerApp["App"] + ConsumerAppContainer["AppContainer"] + ConsumerAppHeader["AppHeader"] + ConsumerDialogManager["DialogManager"] + ConsumerHistoryItem["HistoryItemDisplay"] + ConsumerComposer["Composer"] + ConsumerMainContent["MainContent"] + ConsumerNotifications["Notifications"] + end + + %% --- Provider -> Context Connections --- + A -.-> CtxSession + A -.-> CtxVim + A -.-> CtxSettings + + B -.-> CtxApp + B -.-> CtxConfig + B -.-> CtxUIState + B -.-> CtxUIActions + B -.-> CtxSettings + + %% --- Context -> Consumer Connections --- + CtxSession -.-> ConsumerAppContainer + CtxSession -.-> ConsumerApp + + CtxVim -.-> ConsumerAppContainer + CtxVim -.-> ConsumerComposer + CtxVim -.-> ConsumerApp + + CtxSettings -.-> ConsumerAppContainer + CtxSettings -.-> ConsumerAppHeader + CtxSettings -.-> ConsumerDialogManager + CtxSettings -.-> ConsumerApp + + CtxApp -.-> ConsumerAppHeader + CtxApp -.-> ConsumerNotifications + + CtxConfig -.-> ConsumerAppHeader + CtxConfig -.-> ConsumerHistoryItem + CtxConfig -.-> ConsumerComposer + CtxConfig -.-> ConsumerDialogManager + + + + CtxUIState -.-> ConsumerApp + CtxUIState -.-> ConsumerMainContent + CtxUIState -.-> ConsumerComposer + CtxUIState -.-> ConsumerDialogManager + + CtxUIActions -.-> ConsumerComposer + CtxUIActions -.-> ConsumerDialogManager + + %% --- Apply Styles --- + %% New Elements (Green) + class B,CtxApp,CtxConfig,CtxUIState,CtxUIActions,ConsumerAppHeader,ConsumerDialogManager,ConsumerComposer,ConsumerMainContent,ConsumerNotifications new + + %% Heavily Changed Elements (Blue) + class A,ConsumerApp,ConsumerAppContainer,ConsumerHistoryItem changed + + %% Mostly Unchanged Elements (Gray) + class CtxSession,CtxVim,CtxSettings unchanged + + %% --- Link Styles --- + %% CtxSession (Red) + linkStyle 0,8,9 stroke:#e57373,stroke-width:2px + %% CtxVim (Orange) + linkStyle 1,10,11,12 stroke:#ffb74d,stroke-width:2px + %% CtxSettings (Yellow) + linkStyle 2,7,13,14,15,16 stroke:#fff176,stroke-width:2px + %% CtxApp (Green) + linkStyle 3,17,18 stroke:#81c784,stroke-width:2px + %% CtxConfig (Blue) + linkStyle 4,19,20,21,22 stroke:#64b5f6,stroke-width:2px + %% CtxUIState (Indigo) + linkStyle 5,23,24,25,26 stroke:#7986cb,stroke-width:2px + %% CtxUIActions (Violet) + linkStyle 6,27,28 stroke:#ba68c8,stroke-width:2px diff --git a/docs/mermaid/render-path.mmd b/docs/mermaid/render-path.mmd new file mode 100644 index 0000000000000000000000000000000000000000..5f4c620443fc7d0c4e53880d9ac7fcb10f8ad755 --- /dev/null +++ b/docs/mermaid/render-path.mmd @@ -0,0 +1,64 @@ +graph TD + %% --- Style Definitions --- + classDef new fill:#98fb98,color:#000 + classDef changed fill:#add8e6,color:#000 + classDef unchanged fill:#f0f0f0,color:#000 + classDef dispatcher fill:#f9e79f,color:#000,stroke:#333,stroke-width:1px + classDef container fill:#f5f5f5,color:#000,stroke:#ccc + + %% --- Component Tree --- + subgraph "Entry Point" + A["gemini.tsx"] + end + + subgraph "State & Logic Wrapper" + B["AppContainer.tsx"] + end + + subgraph "Primary Layout" + C["App.tsx"] + end + + A -.-> B + B -.-> C + + subgraph "UI Containers" + direction LR + C -.-> D["MainContent"] + C -.-> G["Composer"] + C -.-> F["DialogManager"] + C -.-> E["Notifications"] + end + + subgraph "MainContent" + direction TB + D -.-> H["AppHeader"] + D -.-> I["HistoryItemDisplay"]:::dispatcher + D -.-> L["ShowMoreLines"] + end + + subgraph "Composer" + direction TB + G -.-> K_Prompt["InputPrompt"] + G -.-> K_Footer["Footer"] + end + + subgraph "DialogManager" + F -.-> J["Various Dialogs
(Auth, Theme, Settings, etc.)"] + end + + %% --- Apply Styles --- + class B,D,E,F,G,H,J,K_Prompt,L new + class A,C,I changed + class K_Footer unchanged + + %% --- Link Styles --- + %% MainContent Branch (Blue) + linkStyle 2,6,7,8 stroke:#64b5f6,stroke-width:2px + %% Composer Branch (Green) + linkStyle 3,9,10 stroke:#81c784,stroke-width:2px + %% DialogManager Branch (Orange) + linkStyle 4,11 stroke:#ffb74d,stroke-width:2px + %% Notifications Branch (Violet) + linkStyle 5 stroke:#ba68c8,stroke-width:2px + diff --git a/docs/reference/commands.md b/docs/reference/commands.md new file mode 100644 index 0000000000000000000000000000000000000000..e95be7826c7a4fb4b1ef2147993843a33170f735 --- /dev/null +++ b/docs/reference/commands.md @@ -0,0 +1,599 @@ +# CLI commands + +Gemini CLI supports several built-in commands to help you manage your session, +customize the interface, and control its behavior. These commands are prefixed +with a forward slash (`/`), an at symbol (`@`), or an exclamation mark (`!`). + +## Slash commands (`/`) + +Slash commands provide meta-level control over the CLI itself. + +### Built-in Commands + +### `/about` + +- **Description:** Show version info. Share this information when filing issues. + +### `/agents` + +- **Description:** Manage local and remote subagents. +- **Sub-commands:** + - **`list`**: + - **Description:** Lists all discovered agents, including built-in, local, + and remote agents. + - **Usage:** `/agents list` + - **`reload`** (alias: `refresh`): + - **Description:** Rescans agent directories (`~/.gemini/agents` and + `.gemini/agents`) and reloads the registry. + - **Usage:** `/agents reload` + - **`enable`**: + - **Description:** Enables a specific subagent. + - **Usage:** `/agents enable ` + - **`disable`**: + - **Description:** Disables a specific subagent. + - **Usage:** `/agents disable ` + - **`config`**: + - **Description:** Opens a configuration dialog for the specified agent to + adjust its model, temperature, or execution limits. + - **Usage:** `/agents config ` + +### `/auth` + +- **Description:** Open a dialog that lets you change the authentication method. + +### `/bug` + +- **Description:** File an issue about Gemini CLI. By default, the issue is + filed within the GitHub repository for Gemini CLI. The string you enter after + `/bug` will become the headline for the bug being filed. The default `/bug` + behavior can be modified using the `advanced.bugCommand` setting in your + `.gemini/settings.json` files. + +### `/chat` + +- **Description:** Alias for `/resume`. Both commands now expose the same + session browser action and checkpoint subcommands. +- **Menu layout when typing `/chat` (or `/resume`)**: + - `-- auto --` + - `list` (selecting this opens the auto-saved session browser) + - `-- checkpoints --` + - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) + - Unique prefixes (for example `/cha` or `/resu`) resolve to the same grouped + menu. +- **Sub-commands:** + - **`debug`** + - **Description:** Export the most recent API request as a JSON payload. + - **`delete `** + - **Description:** Deletes a saved conversation checkpoint. + - **Equivalent:** `/resume delete ` + - **`list`** + - **Description:** Lists available tags for manually saved checkpoints. + - **Note:** This command only lists chats saved within the current project. + Because chat history is project-scoped, chats saved in other project + directories will not be displayed. + - **Equivalent:** `/resume list` + - **`resume `** + - **Description:** Resumes a conversation from a previous save. + - **Note:** You can only resume chats that were saved within the current + project. To resume a chat from a different project, you must run the + Gemini CLI from that project's directory. + - **Equivalent:** `/resume resume ` + - **`save `** + - **Description:** Saves the current conversation history. You must add a + `` for identifying the conversation state. + - **Details on checkpoint location:** The default locations for saved chat + checkpoints are: + - Linux/macOS: `~/.gemini/tmp//` + - Windows: `C:\Users\\.gemini\tmp\\` + - **Behavior:** Chats are saved into a project-specific directory, + determined by where you run the CLI. Consequently, saved chats are only + accessible when working within that same project. + - **Note:** These checkpoints are for manually saving and resuming + conversation states. For automatic checkpoints created before file + modifications, see the + [Checkpointing documentation](../cli/checkpointing.md). + - **Equivalent:** `/resume save ` + - **`share [filename]`** + - **Description:** Writes the current conversation to a provided Markdown or + JSON file. If no filename is provided, then the CLI will generate one. + - **Usage:** `/chat share file.md` or `/chat share file.json`. + - **Equivalent:** `/resume share [filename]` + +### `/clear` + +- **Description:** Clear the agent conversation context (active conversation + history) and start a new session, in addition to clearing the visible terminal + screen, history, and scrollback within the CLI. +- **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a visual clear + action (note that **Ctrl+L** only clears the terminal display and redraws the + UI, preserving the active conversation context; use `/clear` to start a new + session). + +### `/commands` + +- **Description:** Manage custom slash commands loaded from `.toml` files. +- **Sub-commands:** + - **`list`**: + - **Description:** List available custom command `.toml` files from all + sources (user-level `~/.gemini/commands/`, project-level + `/.gemini/commands/`, and active extensions). + - **Usage:** `/commands list` + - **`reload`**: + - **Description:** Reload custom command definitions from all sources + (user-level `~/.gemini/commands/`, project-level + `/.gemini/commands/`, MCP prompts, and extensions). Use this to + pick up new or modified `.toml` files without restarting the CLI. + - **Usage:** `/commands reload` + +### `/compress` + +- **Description:** Replace the entire chat context with a summary. This saves on + tokens used for future tasks while retaining a high level summary of what has + happened. + +### `/copy` + +- **Description:** Copies the last output produced by Gemini CLI to your + clipboard, for easy sharing or reuse. +- **Behavior:** + - Local sessions use system clipboard tools (pbcopy/xclip/clip). + - Remote sessions (SSH/WSL) use OSC 52 and require terminal support. +- **Note:** This command requires platform-specific clipboard tools to be + installed. + - On Linux, it requires `xclip` or `xsel`. You can typically install them + using your system's package manager. + - On macOS, it requires `pbcopy`, and on Windows, it requires `clip`. These + tools are typically pre-installed on their respective systems. + +### `/directory` (or `/dir`) + +- **Description:** Manage workspace directories for multi-directory support. +- **Sub-commands:** + - **`add`**: + - **Description:** Add a directory to the workspace. The path can be + absolute or relative to the current working directory. Moreover, the + reference from home directory is supported as well. + - **Usage:** `/directory add ,` + - **Note:** Disabled in restrictive sandbox profiles. If you're using that, + use `--include-directories` when starting the session instead. + - **`show`**: + - **Description:** Display all directories added by `/directory add` and + `--include-directories`. + - **Usage:** `/directory show` + +### `/docs` + +- **Description:** Open Gemini CLI documentation in your browser. + +### `/editor` + +- **Description:** Open a dialog for selecting supported editors. + +### `/extensions` + +- **Description:** Manage extensions. See + [Gemini CLI Extensions](../extensions/index.md). +- **Sub-commands:** + - **`config`**: + - **Description:** Configure extension settings. + - **`disable`**: + - **Description:** Disable an extension. + - **`enable`**: + - **Description:** Enable an extension. + - **`explore`**: + - **Description:** Open extensions page in your browser. + - **`install`**: + - **Description:** Install an extension from a git repo or local path. + - **`link`**: + - **Description:** Link an extension from a local path. + - **`list`**: + - **Description:** List active extensions. + - **`restart`**: + - **Description:** Restart all extensions. + - **`uninstall`**: + - **Description:** Uninstall an extension. + - **`update`**: + - **Description:** Update extensions. Usage: update |--all + +### `/help` (or `/?`) + +- **Description:** Display help information about Gemini CLI, including + available commands and their usage. + +### `/hooks` + +- **Description:** Manage hooks, which allow you to intercept and customize + Gemini CLI behavior at specific lifecycle events. +- **Sub-commands:** + - **`disable-all`**: + - **Description:** Disable all enabled hooks. + - **`disable `**: + - **Description:** Disable a hook by name. + - **`enable-all`**: + - **Description:** Enable all disabled hooks. + - **`enable `**: + - **Description:** Enable a hook by name. + - **`list`** (or `show`, `panel`): + - **Description:** Display all registered hooks with their status. + +### `/ide` + +- **Description:** Manage IDE integration. +- **Sub-commands:** + - **`disable`**: + - **Description:** Disable IDE integration. + - **`enable`**: + - **Description:** Enable IDE integration. + - **`install`**: + - **Description:** Install required IDE companion. + - **`status`**: + - **Description:** Check status of IDE integration. + +### `/init` + +- **Description:** To help users easily create a `GEMINI.md` file, this command + analyzes the current directory and generates a tailored context file, making + it simpler for them to provide project-specific instructions to the Gemini + agent. + +### `/mcp` + +- **Description:** Manage configured Model Context Protocol (MCP) servers. +- **Sub-commands:** + - **`auth`**: + - **Description:** Authenticate with an OAuth-enabled MCP server. + - **Usage:** `/mcp auth ` + - **Details:** If `` is provided, it initiates the OAuth flow + for that server. If no server name is provided, it lists all configured + servers that support OAuth authentication. + - **`desc`** + - **Description:** List configured MCP servers and tools with descriptions. + - **`disable`** + - **Description:** Disable an MCP server. + - **`enable`** + - **Description:** Enable a disabled MCP server. + - **`list`** or **`ls`**: + - **Description:** List configured MCP servers and tools. This is the + default action if no subcommand is specified. + - **`reload`**: + - **Description:** Reloads all MCP servers and re-discovers their available + tools. + - **`schema`**: + - **Description:** List configured MCP servers and tools with descriptions + and schemas. + +### `/memory` + +- **Description:** Manage the AI's instructional context (hierarchical memory + loaded from `GEMINI.md` files). +- **Sub-commands:** + - **`list`**: + - **Description:** Lists the paths of the GEMINI.md files in use for + hierarchical memory. + - **`refresh`**: + - **Description:** Reload the hierarchical instructional memory from all + `GEMINI.md` files found in the configured locations (global, + project/ancestors, and sub-directories). This command updates the model + with the latest `GEMINI.md` content. + - **`show`**: + - **Description:** Display the full, concatenated content of the current + hierarchical memory that has been loaded from all `GEMINI.md` files. This + lets you inspect the instructional context being provided to the Gemini + model. + - **Note:** For more details on how `GEMINI.md` files contribute to + hierarchical memory, see the + [CLI Configuration documentation](./configuration.md). + +### `/model` + +- **Description:** Manage model configuration. +- **Sub-commands:** + - **`manage`**: + - **Description:** Opens a dialog to configure the model. + - **`set`**: + - **Description:** Set the model to use. + - **Usage:** `/model set [--persist]` + +### `/permissions` + +- **Description:** Manage folder trust settings and other permissions. +- **Sub-commands:** + - **`trust`**: + - **Description:** Manage folder trust settings. + - **Usage:** `/permissions trust []` + +### `/plan` + +- **Description:** Switch to Plan Mode (read-only) and view the current plan if + one has been generated. + - **Note:** This feature is enabled by default. It can be disabled via the + `general.plan.enabled` setting in your configuration. +- **Sub-commands:** + - **`copy`**: + - **Description:** Copy the currently approved plan to your clipboard. + +### `/policies` + +- **Description:** Manage policies. +- **Sub-commands:** + - **`list`**: + - **Description:** List all active policies grouped by mode. + +### `/privacy` + +- **Description:** Display the Privacy Notice and allow users to select whether + they consent to the collection of their data for service improvement purposes. + +### `/quit` (or `/exit`) + +- **Description:** Exit Gemini CLI. +- **Flags:** + - **`--delete`** _(optional)_: Exit and permanently delete the current + session's history and temporary files (chat recording, tool outputs). Useful + for privacy or one-off tasks where you don't want to leave any traces. + - **Usage:** `/quit --delete` or `/exit --delete` + +### `/restore` + +- **Description:** Restores the project files to the state they were in just + before a tool was executed. This is particularly useful for undoing file edits + made by a tool. If run without a tool call ID, it will list available + checkpoints to restore from. +- **Usage:** `/restore [tool_call_id]` +- **Note:** Only available if checkpointing is configured via + [settings](./configuration.md). See + [Checkpointing documentation](../cli/checkpointing.md) for more details. + +### `/rewind` + +- **Description:** Navigates backward through the conversation history, letting + you review past interactions and potentially revert both chat state and file + changes. +- **Usage:** Press **Esc** twice as a shortcut. +- **Features:** + - **Select Interaction:** Preview user prompts and file changes. + - **Action Selection:** Choose to rewind history only, revert code changes + only, or both. + +### `/resume` + +- **Description:** Browse and resume previous conversation sessions, and manage + manual chat checkpoints. +- **Features:** + - **Auto sessions:** Run `/resume` to open the interactive session browser for + automatically saved conversations. + - **Chat checkpoints:** Use checkpoint subcommands directly (`/resume save`, + `/resume resume`, etc.). + - **Management:** Delete unwanted sessions directly from the browser + - **Resume:** Select any session to resume and continue the conversation + - **Search:** Use `/` to search through conversation content across all + sessions + - **Session Browser:** Interactive interface showing all saved sessions with + timestamps, message counts, and first user message for context + - **Sorting:** Sort sessions by date or message count +- **Note:** All conversations are automatically saved as you chat - no manual + saving required. See [Session Management](../cli/session-management.md) for + complete details. +- **Alias:** `/chat` provides the same behavior and subcommands. +- **Sub-commands:** + - **`list`** + - **Description:** Lists available tags for manual chat checkpoints. + - **`save `** + - **Description:** Saves the current conversation as a tagged checkpoint. + - **`resume `** (alias: `load`) + - **Description:** Loads a previously saved tagged checkpoint. + - **`delete `** + - **Description:** Deletes a tagged checkpoint. + - **`share [filename]`** + - **Description:** Exports the current conversation to Markdown or JSON. + - **`debug`** + - **Description:** Export the most recent API request as JSON payload + (nightly builds). + - **Compatibility alias:** `/resume checkpoints ...` is still accepted for the + same checkpoint commands. + +### `/settings` + +- **Description:** Open the settings editor to view and modify Gemini CLI + settings. +- **Details:** This command provides a user-friendly interface for changing + settings that control the behavior and appearance of Gemini CLI. It is + equivalent to manually editing the `.gemini/settings.json` file, but with + validation and guidance to prevent errors. See the + [settings documentation](../cli/settings.md) for a full list of available + settings. +- **Usage:** Simply run `/settings` and the editor will open. You can then + browse or search for specific settings, view their current values, and modify + them as desired. Changes to some settings are applied immediately, while + others require a restart. + +### `/shells` (or `/bashes`) + +- **Description:** Toggle the background shells view. This lets you view and + manage long-running processes that you've sent to the background. + +### `/setup-github` + +- **Description:** Set up GitHub Actions to triage issues and review PRs with + Gemini. + +### `/skills` + +- **Description:** Manage Agent Skills, which provide on-demand expertise and + specialized workflows. +- **Sub-commands:** + - **`disable `**: + - **Description:** Disable a specific skill by name. + - **Usage:** `/skills disable ` + - **`enable `**: + - **Description:** Enable a specific skill by name. + - **Usage:** `/skills enable ` + - **`list`**: + - **Description:** List all discovered skills and their current status + (enabled/disabled). + - **`reload`**: + - **Description:** Refresh the list of discovered skills from all tiers + (workspace, user, and extensions). + +### `/stats` + +- **Description:** Display detailed statistics for the current Gemini CLI + session. +- **Sub-commands:** + - **`session`**: + - **Description:** Show session-specific usage statistics, including + duration, tool calls, and performance metrics. This is the default view. + - **`model`**: + - **Description:** Show model-specific usage statistics, including token + counts and quota information. + - **`tools`**: + - **Description:** Show tool-specific usage statistics. + +### `/terminal-setup` + +- **Description:** Configure terminal keybindings for multiline input (VS Code, + Cursor, Windsurf). + +### `/theme` + +- **Description:** Open a dialog that lets you change the visual theme of Gemini + CLI. + +### `/tools` + +- **Description:** Display a list of tools that are currently available within + Gemini CLI. +- **Usage:** `/tools [desc]` +- **Sub-commands:** + - **`desc`** or **`descriptions`**: + - **Description:** Show detailed descriptions of each tool, including each + tool's name with its full description as provided to the model. + - **`nodesc`** or **`nodescriptions`**: + - **Description:** Hide tool descriptions, showing only the tool names. + +### `/upgrade` + +- **Description:** Open the Gemini Code Assist upgrade page in your browser. + This lets you upgrade your tier for higher usage limits. +- **Note:** This command is only available when logged in with Google. + +### `/vim` + +- **Description:** Toggle vim mode on or off. When vim mode is enabled, the + input area supports vim-style navigation and editing commands in both NORMAL + and INSERT modes. +- **Features:** + - **Count support:** Prefix commands with numbers (for example, `3h`, `5w`, + `10G`) + - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, + `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` + - **INSERT mode:** Standard text input with escape to return to NORMAL mode + - **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, + `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with + `G` (or `gg` for first line) + - **Persistent setting:** Vim mode preference is saved to + `~/.gemini/settings.json` and restored between sessions + - **Repeat last command:** Use `.` to repeat the last editing operation + - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the + footer + +### Custom commands + +Custom commands allow you to create personalized shortcuts for your most-used +prompts. For detailed instructions on how to create, manage, and use them, see +the dedicated [Custom Commands documentation](../cli/custom-commands.md). + +## Input prompt shortcuts + +These shortcuts apply directly to the input prompt for text manipulation. + +- **Undo:** + + - **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or + **Alt+z** (Linux/WSL) to undo the last action in the input prompt. + +- **Redo:** + - **Keyboard shortcut:** Press **Shift+Cmd+Z** (macOS), or **Shift+Alt+Z** + (Linux/WSL) to redo the last undone action in the input prompt. + +## At commands (`@`) + +At commands are used to include the content of files or directories as part of +your prompt to Gemini. These commands include git-aware filtering. + +- **`@`** + + - **Description:** Inject the content of the specified file or files into your + current prompt. This is useful for asking questions about specific code, + text, or collections of files. + - **Examples:** + - `@path/to/your/file.txt Explain this text.` + - `@src/my_project/ Summarize the code in this directory.` + - `What is this file about? @README.md` + - **Details:** + - If a path to a single file is provided, the content of that file is read. + - If a path to a directory is provided, the command attempts to read the + content of files within that directory and any subdirectories. + - Spaces in paths should be escaped with a backslash (for example, + `@My\ Documents/file.txt`). + - The command uses the `read_many_files` tool internally. The content is + fetched and then inserted into your query before being sent to the Gemini + model. + - **Git-aware filtering:** By default, git-ignored files (like + `node_modules/`, `dist/`, `.env`, `.git/`) are excluded. This behavior can + be changed via the `context.fileFiltering` settings. + - **File types:** The command is intended for text-based files. While it + might attempt to read any file, binary files or very large files might be + skipped or truncated by the underlying `read_many_files` tool to ensure + performance and relevance. The tool indicates if files were skipped. + - **Output:** The CLI will show a tool call message indicating that + `read_many_files` was used, along with a message detailing the status and + the path(s) that were processed. + +- **`@` (Lone at symbol)** + - **Description:** If you type a lone `@` symbol without a path, the query is + passed as-is to the Gemini model. This might be useful if you are + specifically talking _about_ the `@` symbol in your prompt. + +### Error handling for `@` commands + +- If the path specified after `@` is not found or is invalid, an error message + will be displayed, and the query might not be sent to the Gemini model, or it + will be sent without the file content. +- If the `read_many_files` tool encounters an error (for example, permission + issues), this will also be reported. + +## Shell mode and passthrough commands (`!`) + +The `!` prefix lets you interact with your system's shell directly from within +Gemini CLI. + +- **`!`** + + - **Description:** Execute the given `` using `bash` on + Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you + override `ComSpec`). Any output or errors from the command are displayed in + the terminal. + - **Examples:** + - `!ls -la` (executes `ls -la` and returns to Gemini CLI) + - `!git status` (executes `git status` and returns to Gemini CLI) + +- **`!` (Toggle shell mode)** + + - **Description:** Typing `!` on its own toggles shell mode. + - **Entering shell mode:** + - When active, shell mode uses a different coloring and a "Shell Mode + Indicator". + - While in shell mode, text you type is interpreted directly as a shell + command. + - **Exiting shell mode:** + - When exited, the UI reverts to its standard appearance and normal Gemini + CLI behavior resumes. + +- **Caution for all `!` usage:** Commands you execute in shell mode have the + same permissions and impact as if you ran them directly in your terminal. + +- **Environment variable:** When a command is executed via `!` or in shell mode, + the `GEMINI_CLI=1` environment variable is set in the subprocess's + environment. This allows scripts or tools to detect if they are being run from + within Gemini CLI. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000000000000000000000000000000000000..66785882cea7ff374d2da084d9eb031e08d90cf8 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,3091 @@ +# Gemini CLI configuration + +Gemini CLI offers several ways to configure its behavior, including environment +variables, command-line arguments, and settings files. This document outlines +the different configuration methods and available settings. + +## Configuration layers + +Configuration is applied in the following order of precedence (lower numbers are +overridden by higher numbers): + +1. **Default values:** Hardcoded defaults within the application. +2. **System defaults file:** System-wide default settings that can be + overridden by other settings files. +3. **User settings file:** Global settings for the current user. +4. **Project settings file:** Project-specific settings. +5. **System settings file:** System-wide settings that override all other + settings files. +6. **Environment variables:** System-wide or session-specific variables, + potentially loaded from `.env` files. +7. **Command-line arguments:** Values passed when launching the CLI. + +## Settings files + +Gemini CLI uses JSON settings files for persistent configuration. There are four +locations for these files: + + +> [!TIP] +> JSON-aware editors can use autocomplete and validation by pointing to +> the generated schema at `schemas/settings.schema.json` in this repository. +> When working outside the repo, reference the hosted schema at +> `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`. + +- **System defaults file:** + - **Location:** `/etc/gemini-cli/system-defaults.json` (Linux), + `C:\ProgramData\gemini-cli\system-defaults.json` (Windows) or + `/Library/Application Support/GeminiCli/system-defaults.json` (macOS). The + path can be overridden using the `GEMINI_CLI_SYSTEM_DEFAULTS_PATH` + environment variable. + - **Scope:** Provides a base layer of system-wide default settings. These + settings have the lowest precedence and are intended to be overridden by + user, project, or system override settings. +- **User settings file:** + - **Location:** `~/.gemini/settings.json` (where `~` is your home directory). + - **Scope:** Applies to all Gemini CLI sessions for the current user. User + settings override system defaults. +- **Project settings file:** + - **Location:** `.gemini/settings.json` within your project's root directory. + - **Scope:** Applies only when running Gemini CLI from that specific project. + Project settings override user settings and system defaults. +- **System settings file:** + - **Location:** `/etc/gemini-cli/settings.json` (Linux), + `C:\ProgramData\gemini-cli\settings.json` (Windows) or + `/Library/Application Support/GeminiCli/settings.json` (macOS). The path can + be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment + variable. + - **Scope:** Applies to all Gemini CLI sessions on the system, for all users. + System settings act as overrides, taking precedence over all other settings + files. May be useful for system administrators at enterprises to have + controls over users' Gemini CLI setups. + +**Note on environment variables in settings:** String values within your +`settings.json` and `gemini-extension.json` files can reference environment +variables using `$VAR_NAME`, `${VAR_NAME}`, or `${VAR_NAME:-DEFAULT_VALUE}` +syntax. These variables will be automatically resolved when the settings are +loaded. For example, if you have an environment variable `MY_API_TOKEN`, you +could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. If you +want to provide a fallback value, use `${MY_API_TOKEN:-default-token}`. +Additionally, each extension can have its own `.env` file in its directory, +which will be loaded automatically. + +**Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI +in a corporate environment, see the +[Enterprise Configuration](../cli/enterprise.md) documentation. + +### The `.gemini` directory in your project + +In addition to a project settings file, a project's `.gemini` directory can +contain other project-specific files related to Gemini CLI's operation, such as: + +- [Custom sandbox profiles](#sandboxing) (for example, + `.gemini/sandbox-macos-custom.sb`, `.gemini/sandbox.Dockerfile`). + +### Available settings in `settings.json` + +Settings are organized into categories. All settings should be placed within +their corresponding top-level category object in your `settings.json` file. + + + +#### `policyPaths` + +- **`policyPaths`** (array): + - **Description:** Additional policy files or directories to load. + - **Default:** `[]` + - **Requires restart:** Yes + +#### `adminPolicyPaths` + +- **`adminPolicyPaths`** (array): + - **Description:** Additional admin policy files or directories to load. + - **Default:** `[]` + - **Requires restart:** Yes + +#### `general` + +- **`general.preferredEditor`** (enum): + + - **Description:** The preferred editor to open files in. Must be one of the + built-in supported identifiers. Use /editor in the CLI to pick + interactively, or leave unset to use $VISUAL/$EDITOR. + - **Default:** `undefined` + - **Values:** `"vscode"`, `"vscodium"`, `"windsurf"`, `"cursor"`, `"zed"`, + `"antigravity"`, `"sublimetext"`, `"lapce"`, `"nova"`, `"bbedit"`, `"vim"`, + `"neovim"`, `"emacs"`, `"hx"`, `"emacsclient"`, `"micro"` + +- **`general.openEditorInNewWindow`** (boolean): + + - **Description:** Open VS Code-family editors in a new window when editing + files. + - **Default:** `false` + +- **`general.vimMode`** (boolean): + + - **Description:** Enable Vim keybindings + - **Default:** `false` + +- **`general.defaultApprovalMode`** (enum): + + - **Description:** The default approval mode for tool execution. 'default' + prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is + read-only mode. YOLO mode (auto-approve all actions) can only be enabled via + command line (--yolo or --approval-mode=yolo). + - **Default:** `"default"` + - **Values:** `"default"`, `"auto_edit"`, `"plan"` + +- **`general.devtools`** (boolean): + + - **Description:** Enable DevTools inspector on launch. + - **Default:** `false` + +- **`general.enableAutoUpdate`** (boolean): + + - **Description:** Enable automatic updates. + - **Default:** `true` + +- **`general.enableAutoUpdateNotification`** (boolean): + + - **Description:** Enable update notification prompts. + - **Default:** `true` + +- **`general.enableNotifications`** (boolean): + + - **Description:** Enable terminal run-event notifications for action-required + prompts and session completion. + - **Default:** `false` + +- **`general.notificationMethod`** (enum): + + - **Description:** How to send terminal notifications. + - **Default:** `"auto"` + - **Values:** `"auto"`, `"osc9"`, `"osc777"`, `"bell"` + +- **`general.checkpointing.enabled`** (boolean): + + - **Description:** Enable session checkpointing for recovery + - **Default:** `false` + - **Requires restart:** Yes + +- **`general.plan.enabled`** (boolean): + + - **Description:** Enable Plan Mode for read-only safety during planning. + - **Default:** `true` + - **Requires restart:** Yes + +- **`general.plan.directory`** (string): + + - **Description:** The directory where planning artifacts are stored. If not + specified, defaults to the system temporary directory. A custom directory + requires a policy to allow write access in Plan Mode. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`general.plan.modelRouting`** (boolean): + + - **Description:** Automatically switch between Pro and Flash models based on + Plan Mode status. Uses Pro for the planning phase and Flash for the + implementation phase. + - **Default:** `true` + +- **`general.retryFetchErrors`** (boolean): + + - **Description:** Retry on "exception TypeError: fetch failed sending + request" errors. + - **Default:** `true` + +- **`general.maxAttempts`** (number): + + - **Description:** Maximum number of attempts for requests to the main chat + model. Cannot exceed 10. + - **Default:** `10` + +- **`general.debugKeystrokeLogging`** (boolean): + + - **Description:** Enable debug logging of keystrokes to the console. + - **Default:** `false` + +- **`general.sessionRetention.enabled`** (boolean): + + - **Description:** Enable automatic session cleanup + - **Default:** `true` + +- **`general.sessionRetention.maxAge`** (string): + + - **Description:** Automatically delete chats older than this time period + (e.g., "30d", "7d", "24h", "1w") + - **Default:** `"30d"` + +- **`general.sessionRetention.maxCount`** (number): + + - **Description:** Alternative: Maximum number of sessions to keep (most + recent) + - **Default:** `undefined` + +- **`general.sessionRetention.minRetention`** (string): + + - **Description:** Minimum retention period (safety limit, defaults to "1d") + - **Default:** `"1d"` + +- **`general.topicUpdateNarration`** (boolean): + + - **Description:** Enable the Topic & Update communication model for reduced + chattiness and structured progress reporting. + - **Default:** `true` + +- **`general.logRagSnippets`** (boolean): + - **Description:** Log full Code Customization (RAG) retrieved snippets to a + local file for debugging. + - **Default:** `false` + +#### `output` + +- **`output.format`** (enum): + - **Description:** The format of the CLI output. Can be `text` or `json`. + - **Default:** `"text"` + - **Values:** `"text"`, `"json"` + +#### `ui` + +- **`ui.debugRainbow`** (boolean): + + - **Description:** Enable debug rainbow rendering. Only useful for debugging + rendering bugs and performance issues. + - **Default:** `false` + - **Requires restart:** Yes + +- **`ui.theme`** (string): + + - **Description:** The color theme for the UI. See the CLI themes guide for + available options. + - **Default:** `undefined` + +- **`ui.autoThemeSwitching`** (boolean): + + - **Description:** Automatically switch between default light and dark themes + based on terminal background color. + - **Default:** `true` + +- **`ui.terminalBackgroundPollingInterval`** (number): + + - **Description:** Interval in seconds to poll the terminal background color. + - **Default:** `60` + +- **`ui.customThemes`** (object): + + - **Description:** Custom theme definitions. + - **Default:** `{}` + +- **`ui.hideWindowTitle`** (boolean): + + - **Description:** Hide the window title bar + - **Default:** `false` + - **Requires restart:** Yes + +- **`ui.inlineThinkingMode`** (enum): + + - **Description:** Display model thinking inline: off or full. + - **Default:** `"off"` + - **Values:** `"off"`, `"full"` + +- **`ui.showStatusInTitle`** (boolean): + + - **Description:** Show Gemini CLI model thoughts in the terminal window title + during the working phase + - **Default:** `false` + +- **`ui.dynamicWindowTitle`** (boolean): + + - **Description:** Update the terminal window title with current status icons + (Ready: β—‡, Action Required: βœ‹, Working: ✦) + - **Default:** `true` + +- **`ui.showHomeDirectoryWarning`** (boolean): + + - **Description:** Show a warning when running Gemini CLI in the home + directory. + - **Default:** `true` + - **Requires restart:** Yes + +- **`ui.showCompatibilityWarnings`** (boolean): + + - **Description:** Show warnings about terminal or OS compatibility issues. + - **Default:** `true` + - **Requires restart:** Yes + +- **`ui.hideTips`** (boolean): + + - **Description:** Hide helpful tips in the UI + - **Default:** `false` + +- **`ui.escapePastedAtSymbols`** (boolean): + + - **Description:** When enabled, @ symbols in pasted text are escaped to + prevent unintended @path expansion. + - **Default:** `false` + +- **`ui.showShortcutsHint`** (boolean): + + - **Description:** Show the "? for shortcuts" hint above the input. + - **Default:** `true` + +- **`ui.compactToolOutput`** (boolean): + + - **Description:** Display tool outputs (like directory listings and file + reads) in a compact, structured format. + - **Default:** `true` + +- **`ui.hideBanner`** (boolean): + + - **Description:** Hide the application banner + - **Default:** `false` + +- **`ui.hideContextSummary`** (boolean): + + - **Description:** Hide the context summary (GEMINI.md, MCP servers) above the + input. + - **Default:** `false` + +- **`ui.footer.items`** (array): + + - **Description:** List of item IDs to display in the footer. Rendered in + order + - **Default:** `undefined` + +- **`ui.footer.showLabels`** (boolean): + + - **Description:** Display a second line above the footer items with + descriptive headers (e.g., /model). + - **Default:** `true` + +- **`ui.footer.hideCWD`** (boolean): + + - **Description:** Hide the current working directory in the footer. + - **Default:** `false` + +- **`ui.footer.hideSandboxStatus`** (boolean): + + - **Description:** Hide the sandbox status indicator in the footer. + - **Default:** `false` + +- **`ui.footer.hideModelInfo`** (boolean): + + - **Description:** Hide the model name and context usage in the footer. + - **Default:** `false` + +- **`ui.footer.hideContextPercentage`** (boolean): + + - **Description:** Hides the context window usage percentage. + - **Default:** `true` + +- **`ui.hideFooter`** (boolean): + + - **Description:** Hide the footer from the UI + - **Default:** `false` + +- **`ui.collapseDrawerDuringApproval`** (boolean): + + - **Description:** Whether to collapse the UI drawer when a tool is awaiting + confirmation. + - **Default:** `true` + +- **`ui.showMemoryUsage`** (boolean): + + - **Description:** Display memory usage information in the UI + - **Default:** `false` + +- **`ui.showLineNumbers`** (boolean): + + - **Description:** Show line numbers in the chat. + - **Default:** `true` + +- **`ui.showCitations`** (boolean): + + - **Description:** Show citations for generated text in the chat. + - **Default:** `false` + +- **`ui.showModelInfoInChat`** (boolean): + + - **Description:** Show the model name in the chat for each model turn. + - **Default:** `false` + +- **`ui.showUserIdentity`** (boolean): + + - **Description:** Show the signed-in user's identity (e.g. email) in the UI. + - **Default:** `true` + +- **`ui.useAlternateBuffer`** (boolean): + + - **Description:** Use an alternate screen buffer for the UI, preserving shell + history. + - **Default:** `false` + - **Requires restart:** Yes + +- **`ui.renderProcess`** (boolean): + + - **Description:** Enable Ink render process for the UI. + - **Default:** `true` + - **Requires restart:** Yes + +- **`ui.terminalBuffer`** (boolean): + + - **Description:** Use the new terminal buffer architecture for rendering. + - **Default:** `false` + - **Requires restart:** Yes + +- **`ui.useBackgroundColor`** (boolean): + + - **Description:** Whether to use background colors in the UI. + - **Default:** `true` + +- **`ui.incrementalRendering`** (boolean): + + - **Description:** Enable incremental rendering for the UI. This option will + reduce flickering but may cause rendering artifacts. Only supported when + useAlternateBuffer is enabled. + - **Default:** `true` + - **Requires restart:** Yes + +- **`ui.showSpinner`** (boolean): + + - **Description:** Show the spinner during operations. + - **Default:** `true` + +- **`ui.loadingPhrases`** (enum): + + - **Description:** What to show while the model is working: tips, witty + comments, all, or off. + - **Default:** `"off"` + - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` + +- **`ui.errorVerbosity`** (enum): + + - **Description:** Controls whether recoverable errors are hidden (low) or + fully shown (full). + - **Default:** `"low"` + - **Values:** `"low"`, `"full"` + +- **`ui.customWittyPhrases`** (array): + + - **Description:** Custom witty phrases to display during loading. When + provided, the CLI cycles through these instead of the defaults. + - **Default:** `[]` + +- **`ui.accessibility.enableLoadingPhrases`** (boolean): + + - **Description:** @deprecated Use ui.loadingPhrases instead. Enable loading + phrases during operations. + - **Default:** `true` + - **Requires restart:** Yes + +- **`ui.accessibility.screenReader`** (boolean): + - **Description:** Render output in plain-text to be more screen reader + accessible + - **Default:** `false` + - **Requires restart:** Yes + +#### `ide` + +- **`ide.enabled`** (boolean): + + - **Description:** Enable IDE integration mode. + - **Default:** `false` + - **Requires restart:** Yes + +- **`ide.hasSeenNudge`** (boolean): + - **Description:** Whether the user has seen the IDE integration nudge. + - **Default:** `false` + +#### `privacy` + +- **`privacy.usageStatisticsEnabled`** (boolean): + - **Description:** Enable collection of usage statistics + - **Default:** `true` + - **Requires restart:** Yes + +#### `billing` + +- **`billing.overageStrategy`** (enum): + + - **Description:** How to handle quota exhaustion when AI credits are + available. 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + - **Default:** `"ask"` + - **Values:** `"ask"`, `"always"`, `"never"` + +- **`billing.vertexAi.requestType`** (enum): + + - **Description:** Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI + requests. + - **Default:** `undefined` + - **Values:** `"dedicated"`, `"shared"` + - **Requires restart:** Yes + +- **`billing.vertexAi.sharedRequestType`** (enum): + - **Description:** Sets the X-Vertex-AI-LLM-Shared-Request-Type header for + Vertex AI requests. + - **Default:** `undefined` + - **Values:** `"priority"`, `"flex"` + - **Requires restart:** Yes + +#### `model` + +- **`model.name`** (string): + + - **Description:** The Gemini model to use for conversations. + - **Default:** `undefined` + +- **`model.maxSessionTurns`** (number): + + - **Description:** Maximum number of user/model/tool turns to keep in a + session. -1 means unlimited. + - **Default:** `-1` + +- **`model.summarizeToolOutput`** (object): + + - **Description:** Enables or disables summarization of tool output. Configure + per-tool token budgets (for example {"run_shell_command": {"tokenBudget": + 2000}}). Currently only the run_shell_command tool supports summarization. + - **Default:** `undefined` + +- **`model.compressionThreshold`** (number): + + - **Description:** The fraction of context usage at which to trigger context + compression (e.g. 0.2, 0.3). + - **Default:** `0.5` + - **Requires restart:** Yes + +- **`model.disableLoopDetection`** (boolean): + + - **Description:** Disable automatic detection and prevention of infinite + loops. + - **Default:** `false` + - **Requires restart:** Yes + +- **`model.skipNextSpeakerCheck`** (boolean): + - **Description:** Skip the next speaker check. + - **Default:** `true` + +#### `modelConfigs` + +- **`modelConfigs.aliases`** (object): + + - **Description:** Named presets for model configs. Can be used in place of a + model name and can inherit from other aliases using an `extends` property. + - **Default:** + + ```json + { + "base": { + "modelConfig": { + "generateContentConfig": { + "temperature": 0, + "topP": 1 + } + } + }, + "chat-base": { + "extends": "base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "includeThoughts": true + }, + "temperature": 1, + "topP": 0.95, + "topK": 64 + } + } + }, + "chat-base-2.5": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 8192 + } + } + } + }, + "chat-base-3": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingLevel": "HIGH" + } + } + } + }, + "gemini-3-pro-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "gemini-3-flash-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, + "gemini-3.1-pro-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3.1-pro-preview" + } + }, + "gemini-3.1-pro-preview-customtools": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3.1-pro-preview-customtools" + } + }, + "gemini-3.1-flash-lite-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3.1-flash-lite-preview" + } + }, + "gemini-2.5-pro": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "gemini-2.5-flash": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "gemini-2.5-flash-lite": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "gemini-3.1-flash-lite": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3.1-flash-lite" + } + }, + "gemini-3.5-flash": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3.5-flash" + } + }, + "gemma-4-31b-it": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemma-4-31b-it" + } + }, + "gemma-4-26b-a4b-it": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemma-4-26b-a4b-it" + } + }, + "gemini-2.5-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "gemini-3-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, + "gemini-3.5-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-3.5-flash" + } + }, + "classifier": { + "extends": "base", + "modelConfig": { + "model": "flash-lite", + "generateContentConfig": { + "maxOutputTokens": 1024, + "thinkingConfig": { + "thinkingBudget": 512 + } + } + } + }, + "prompt-completion": { + "extends": "base", + "modelConfig": { + "model": "flash-lite", + "generateContentConfig": { + "temperature": 0.3, + "maxOutputTokens": 16000, + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "fast-ack-helper": { + "extends": "base", + "modelConfig": { + "model": "flash-lite", + "generateContentConfig": { + "temperature": 0.2, + "maxOutputTokens": 120, + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "edit-corrector": { + "extends": "base", + "modelConfig": { + "model": "flash-lite", + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "summarizer-default": { + "extends": "base", + "modelConfig": { + "model": "flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "summarizer-shell": { + "extends": "base", + "modelConfig": { + "model": "flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "web-search": { + "extends": "gemini-3-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + } + ] + } + } + }, + "web-fetch": { + "extends": "gemini-3-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "urlContext": {} + } + ] + } + } + }, + "web-fetch-fallback": { + "extends": "gemini-3-flash-base", + "modelConfig": {} + }, + "loop-detection": { + "extends": "gemini-3-flash-base", + "modelConfig": {} + }, + "loop-detection-double-check": { + "extends": "base", + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "llm-edit-fixer": { + "extends": "gemini-3-flash-base", + "modelConfig": {} + }, + "next-speaker-checker": { + "extends": "gemini-3-flash-base", + "modelConfig": {} + }, + "context-snapshotter": { + "extends": "gemini-3-flash-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingLevel": "HIGH" + }, + "temperature": 1, + "topP": 0.95, + "topK": 64 + } + } + }, + "chat-compression-3-pro": { + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "chat-compression-3-flash": { + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, + "chat-compression-3.1-flash-lite": { + "modelConfig": { + "model": "gemini-3.1-flash-lite" + } + }, + "chat-compression-2.5-pro": { + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "chat-compression-2.5-flash": { + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "chat-compression-2.5-flash-lite": { + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "chat-compression-default": { + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "agent-history-provider-summarizer": { + "modelConfig": { + "model": "gemini-3-flash-preview" + } + } + } + ``` + +- **`modelConfigs.customAliases`** (object): + + - **Description:** Custom named presets for model configs. These are merged + with (and override) the built-in aliases. + - **Default:** `{}` + +- **`modelConfigs.customOverrides`** (array): + + - **Description:** Custom model config overrides. These are merged with (and + added to) the built-in overrides. + - **Default:** `[]` + +- **`modelConfigs.overrides`** (array): + + - **Description:** Apply specific configuration overrides based on matches, + with a primary key of model (or alias). The most specific match will be + used. + - **Default:** `[]` + +- **`modelConfigs.modelDefinitions`** (object): + + - **Description:** Registry of model metadata, including tier, family, and + features. + - **Default:** + + ```json + { + "gemini-3.1-flash-lite": { + "tier": "flash-lite", + "family": "gemini-3", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, + "gemini-3.1-pro-preview": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3.1-pro-preview-customtools": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3-pro-preview": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3-flash-preview": { + "tier": "flash", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, + "gemini-3.5-flash": { + "tier": "flash", + "family": "gemini-3", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, + "gemini-2.5-pro": { + "tier": "pro", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemini-2.5-flash": { + "tier": "flash", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemini-2.5-flash-lite": { + "tier": "flash-lite", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemma-4-31b-it": { + "displayName": "gemma-4-31b-it", + "tier": "custom", + "family": "gemma-4", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "gemma-4-26b-a4b-it": { + "displayName": "gemma-4-26b-a4b-it", + "tier": "custom", + "family": "gemma-4", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "auto": { + "displayName": "Auto", + "tier": "auto", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "pro": { + "tier": "pro", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "flash": { + "tier": "flash", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "flash-lite": { + "tier": "flash-lite", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "auto-gemini-3": { + "tier": "auto", + "family": "gemini-3", + "isPreview": true, + "isVisible": false + }, + "auto-gemini-2.5": { + "tier": "auto", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": false + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.modelIdResolutions`** (object): + + - **Description:** Rules for resolving requested model names to concrete model + IDs based on context. + - **Default:** + + ```json + { + "gemma-4-31b-it": { + "default": "gemma-4-31b-it" + }, + "gemma-4-26b-a4b-it": { + "default": "gemma-4-26b-a4b-it" + }, + "gemini-3.1-pro-preview": { + "default": "gemini-3.1-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + } + ] + }, + "gemini-3.1-pro-preview-customtools": { + "default": "gemini-3.1-pro-preview-customtools", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3-flash-preview": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false, + "useGemini3_5Flash": true + }, + "target": "gemini-3.5-flash" + }, + { + "condition": { + "hasAccessToPreview": false, + "useGemini3_5Flash": false + }, + "target": "gemini-2.5-flash" + } + ] + }, + "gemini-3.5-flash": { + "default": "gemini-3.5-flash", + "contexts": [ + { + "condition": { + "useGemini3_5Flash": false, + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + }, + { + "condition": { + "useGemini3_5Flash": false + }, + "target": "gemini-3-flash-preview" + } + ] + }, + "gemini-2.5-flash": { + "default": "gemini-2.5-flash", + "contexts": [ + { + "condition": { + "useGemini3_5Flash": true + }, + "target": "gemini-3.5-flash" + } + ] + }, + "gemini-3-pro-preview": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "pro": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "gemini-3.1-flash-lite": { + "default": "gemini-3.1-flash-lite" + }, + "flash": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "useGemini3_5Flash": true + }, + "target": "gemini-3.5-flash" + }, + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, + "flash-lite": { + "default": "gemini-3.1-flash-lite" + }, + "auto-gemini-3": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto-gemini-2.5": { + "default": "gemini-2.5-pro" + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.classifierIdResolutions`** (object): + + - **Description:** Rules for resolving classifier tiers (flash, pro) to + concrete model IDs. + - **Default:** + + ```json + { + "flash": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "useGemini3_5Flash": true + }, + "target": "gemini-3.5-flash" + }, + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + }, + { + "condition": { + "requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"] + }, + "target": "gemini-2.5-flash" + } + ] + }, + "pro": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"] + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.modelChains`** (object): + + - **Description:** Availability policy chains defining fallback behavior for + models. + - **Default:** + + ```json + { + "preview": [ + { + "model": "gemini-3-pro-preview", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "maxAttempts": 10, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "auto-preview": [ + { + "model": "gemini-3-pro-preview", + "maxAttempts": 3, + "actions": { + "terminal": "prompt", + "transient": "silent", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "sticky_retry", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "maxAttempts": 10, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "default": [ + { + "model": "gemini-2.5-pro", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "sticky_retry", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "maxAttempts": 10, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "auto-default": [ + { + "model": "gemini-2.5-pro", + "maxAttempts": 3, + "actions": { + "terminal": "prompt", + "transient": "silent", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "sticky_retry", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "maxAttempts": 10, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "lite": [ + { + "model": "flash-lite", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-pro", + "isLastResort": true, + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ] + } + ``` + + - **Requires restart:** Yes + +#### `agents` + +- **`agents.overrides`** (object): + + - **Description:** Override settings for specific agents, e.g. to disable the + agent, set a custom model config, or run config. + - **Default:** `{}` + - **Requires restart:** Yes + +- **`agents.browser.sessionMode`** (enum): + + - **Description:** Session mode: 'persistent', 'isolated', or 'existing'. + - **Default:** `"persistent"` + - **Values:** `"persistent"`, `"isolated"`, `"existing"` + - **Requires restart:** Yes + +- **`agents.browser.headless`** (boolean): + + - **Description:** Run browser in headless mode. + - **Default:** `false` + - **Requires restart:** Yes + +- **`agents.browser.profilePath`** (string): + + - **Description:** Path to browser profile directory for session persistence. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`agents.browser.visualModel`** (string): + + - **Description:** Model for the visual agent's analyze_screenshot tool. When + set, enables the tool. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`agents.browser.allowedDomains`** (array): + + - **Description:** A list of allowed domains for the browser agent (e.g., + ["github.com", "*.google.com"]). + - **Default:** + + ```json + ["github.com", "*.google.com", "localhost"] + ``` + + - **Requires restart:** Yes + +- **`agents.browser.disableUserInput`** (boolean): + + - **Description:** Disable user input on browser window during automation. + - **Default:** `true` + +- **`agents.browser.maxActionsPerTask`** (number): + + - **Description:** The maximum number of tool calls allowed per browser task. + Enforcement is hard: the agent will be terminated when the limit is reached. + - **Default:** `100` + +- **`agents.browser.confirmSensitiveActions`** (boolean): + + - **Description:** Require manual confirmation for sensitive browser actions + (e.g., fill_form, evaluate_script). + - **Default:** `false` + - **Requires restart:** Yes + +- **`agents.browser.blockFileUploads`** (boolean): + - **Description:** Hard-block file upload requests from the browser agent. + - **Default:** `false` + - **Requires restart:** Yes + +#### `context` + +- **`context.fileName`** (string | string[]): + + - **Description:** The name of the context file or files to load into memory. + Accepts either a single string or an array of strings. + - **Default:** `undefined` + +- **`context.importFormat`** (string): + + - **Description:** The format to use when importing memory. + - **Default:** `undefined` + +- **`context.includeDirectoryTree`** (boolean): + + - **Description:** Whether to include the directory tree of the current + working directory in the initial request to the model. + - **Default:** `true` + +- **`context.discoveryMaxDirs`** (number): + + - **Description:** Maximum number of directories to search for memory. + - **Default:** `200` + +- **`context.memoryBoundaryMarkers`** (array): + + - **Description:** File or directory names that mark the boundary for + GEMINI.md discovery. The upward traversal stops at the first directory + containing any of these markers. An empty array disables parent traversal. + - **Default:** + + ```json + [".git"] + ``` + + - **Requires restart:** Yes + +- **`context.includeDirectories`** (array): + + - **Description:** Additional directories to include in the workspace context. + Missing directories will be skipped with a warning. + - **Default:** `[]` + +- **`context.loadMemoryFromIncludeDirectories`** (boolean): + + - **Description:** Controls how /memory reload loads GEMINI.md files. When + true, include directories are scanned; when false, only the current + directory is used. + - **Default:** `false` + +- **`context.fileFiltering.respectGitIgnore`** (boolean): + + - **Description:** Respect .gitignore files when searching. + - **Default:** `true` + - **Requires restart:** Yes + +- **`context.fileFiltering.respectGeminiIgnore`** (boolean): + + - **Description:** Respect .geminiignore files when searching. + - **Default:** `true` + - **Requires restart:** Yes + +- **`context.fileFiltering.enableFileWatcher`** (boolean): + + - **Description:** Enable file watcher updates for @ file suggestions + (experimental). + - **Default:** `false` + - **Requires restart:** Yes + +- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean): + + - **Description:** Enable recursive file search functionality when completing + @ references in the prompt. + - **Default:** `true` + - **Requires restart:** Yes + +- **`context.fileFiltering.enableFuzzySearch`** (boolean): + + - **Description:** Enable fuzzy search when searching for files. + - **Default:** `true` + - **Requires restart:** Yes + +- **`context.fileFiltering.customIgnoreFilePaths`** (array): + - **Description:** Additional ignore file paths to respect. These files take + precedence over .geminiignore and .gitignore. Files earlier in the array + take precedence over files later in the array, e.g. the first file takes + precedence over the second one. + - **Default:** `[]` + - **Requires restart:** Yes + +#### `tools` + +- **`tools.sandbox`** (string): + + - **Description:** Legacy full-process sandbox execution environment. Set to a + boolean to enable or disable the sandbox, provide a string path to a sandbox + profile, or specify an explicit sandbox command (e.g., "docker", "podman", + "lxc", "windows-native"). + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.sandboxAllowedPaths`** (array): + + - **Description:** List of additional paths that the sandbox is allowed to + access. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`tools.sandboxNetworkAccess`** (boolean): + + - **Description:** Whether the sandbox is allowed to access the network. + - **Default:** `false` + - **Requires restart:** Yes + +- **`tools.shell.enableInteractiveShell`** (boolean): + + - **Description:** Use node-pty for an interactive shell experience. Fallback + to child_process still applies. + - **Default:** `true` + - **Requires restart:** Yes + +- **`tools.shell.backgroundCompletionBehavior`** (enum): + + - **Description:** Controls what happens when a background shell command + finishes. 'silent' (default): quietly exits in background. 'inject': + automatically returns output to agent. 'notify': shows brief message in + chat. + - **Default:** `"silent"` + - **Values:** `"silent"`, `"inject"`, `"notify"` + +- **`tools.shell.pager`** (string): + + - **Description:** The pager command to use for shell output. Defaults to + `cat`. + - **Default:** `"cat"` + +- **`tools.shell.showColor`** (boolean): + + - **Description:** Show color in shell output. + - **Default:** `true` + +- **`tools.shell.inactivityTimeout`** (number): + + - **Description:** The maximum time in seconds allowed without output from the + shell command. Defaults to 5 minutes. + - **Default:** `300` + +- **`tools.shell.enableShellOutputEfficiency`** (boolean): + + - **Description:** Enable shell output efficiency optimizations for better + performance. + - **Default:** `true` + +- **`tools.core`** (array): + + - **Description:** Restrict the set of built-in tools with an allowlist. Match + semantics mirror tools.allowed; see the built-in tools documentation for + available names. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.allowed`** (array): + + - **Description:** Tool names that bypass the confirmation dialog. Useful for + trusted commands (for example ["run_shell_command(git)", + "run_shell_command(npm test)"]). See shell tool command restrictions for + matching details. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.confirmationRequired`** (array): + + - **Description:** Tool names that always require user confirmation. Takes + precedence over allowed tools and core tool allowlists. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.exclude`** (array): + + - **Description:** Tool names to exclude from discovery. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.discoveryCommand`** (string): + + - **Description:** Command to run for tool discovery. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.callCommand`** (string): + + - **Description:** Defines a custom shell command for invoking discovered + tools. The command must take the tool name as the first argument, read JSON + arguments from stdin, and emit JSON results on stdout. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`tools.useRipgrep`** (boolean): + + - **Description:** Use ripgrep for file content search instead of the fallback + implementation. Provides faster search performance. + - **Default:** `true` + +- **`tools.truncateToolOutputThreshold`** (number): + + - **Description:** Maximum characters to show when truncating large tool + outputs. Set to 0 or negative to disable truncation. + - **Default:** `40000` + - **Requires restart:** Yes + +- **`tools.disableLLMCorrection`** (boolean): + - **Description:** Disable LLM-based error correction for edit tools. When + enabled, tools will fail immediately if exact string matches are not found, + instead of attempting to self-correct. + - **Default:** `true` + - **Requires restart:** Yes + +#### `mcp` + +- **`mcp.serverCommand`** (string): + + - **Description:** Command to start an MCP server. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`mcp.allowed`** (array): + + - **Description:** A list of MCP servers to allow. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`mcp.excluded`** (array): + - **Description:** A list of MCP servers to exclude. + - **Default:** `undefined` + - **Requires restart:** Yes + +#### `useWriteTodos` + +- **`useWriteTodos`** (boolean): + - **Description:** Enable the write_todos tool. + - **Default:** `true` + +#### `security` + +- **`security.toolSandboxing`** (boolean): + + - **Description:** Tool-level sandboxing. Isolates individual tools instead of + the entire CLI process. + - **Default:** `false` + - **Requires restart:** Yes + +- **`security.disableYoloMode`** (boolean): + + - **Description:** Disable YOLO mode, even if enabled by a flag. + - **Default:** `false` + - **Requires restart:** Yes + +- **`security.disableAlwaysAllow`** (boolean): + + - **Description:** Disable "Always allow" options in tool confirmation + dialogs. + - **Default:** `false` + - **Requires restart:** Yes + +- **`security.enablePermanentToolApproval`** (boolean): + + - **Description:** Enable the "Allow for all future sessions" option in tool + confirmation dialogs. + - **Default:** `false` + +- **`security.autoAddToPolicyByDefault`** (boolean): + + - **Description:** When enabled, the "Allow for all future sessions" option + becomes the default choice for low-risk tools in trusted workspaces. + - **Default:** `false` + +- **`security.blockGitExtensions`** (boolean): + + - **Description:** Blocks installing and loading extensions from Git. + - **Default:** `false` + - **Requires restart:** Yes + +- **`security.allowedExtensions`** (array): + + - **Description:** List of Regex patterns for allowed extensions. If nonempty, + only extensions that match the patterns in this list are allowed. Overrides + the blockGitExtensions setting. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`security.folderTrust.enabled`** (boolean): + + - **Description:** Setting to track whether Folder trust is enabled. + - **Default:** `true` + - **Requires restart:** Yes + +- **`security.environmentVariableRedaction.allowed`** (array): + + - **Description:** Environment variables to always allow (bypass redaction). + - **Default:** `[]` + - **Requires restart:** Yes + +- **`security.environmentVariableRedaction.blocked`** (array): + + - **Description:** Environment variables to always redact. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`security.environmentVariableRedaction.enabled`** (boolean): + + - **Description:** Enable redaction of environment variables that may contain + secrets. + - **Default:** `false` + - **Requires restart:** Yes + +- **`security.auth.selectedType`** (string): + + - **Description:** The currently selected authentication type. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`security.auth.enforcedType`** (string): + + - **Description:** The required auth type. If this does not match the selected + auth type, the user will be prompted to re-authenticate. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`security.auth.useExternal`** (boolean): + + - **Description:** Whether to use an external authentication flow. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`security.enableConseca`** (boolean): + - **Description:** Enable the context-aware security checker. This feature + uses an LLM to dynamically generate and enforce security policies for tool + use based on your prompt, providing an additional layer of protection + against unintended actions. + - **Default:** `false` + - **Requires restart:** Yes + +#### `advanced` + +- **`advanced.autoConfigureMemory`** (boolean): + + - **Description:** Automatically configure Node.js memory limits. Note: + Because memory is allocated during the initial process boot, this setting is + only read from the global user settings file and ignores workspace-level + overrides. + - **Default:** `true` + - **Requires restart:** Yes + +- **`advanced.dnsResolutionOrder`** (string): + + - **Description:** The DNS resolution order. + - **Default:** `undefined` + - **Requires restart:** Yes + +- **`advanced.excludedEnvVars`** (array): + + - **Description:** Environment variables to exclude from project context. + - **Default:** + + ```json + ["DEBUG", "DEBUG_MODE"] + ``` + +- **`advanced.ignoreLocalEnv`** (boolean): + + - **Description:** Whether to ignore generic .env files in the project + directory. + - **Default:** `false` + - **Requires restart:** Yes + +- **`advanced.bugCommand`** (object): + - **Description:** Configuration for the bug report command. + - **Default:** `undefined` + +#### `experimental` + +- **`experimental.gemma`** (boolean): + + - **Description:** Enable access to Gemma 4 models via Gemini API. + - **Default:** `true` + - **Requires restart:** Yes + +- **`experimental.voiceMode`** (boolean): + + - **Description:** Enable experimental voice dictation and commands (/voice, + /voice model). + - **Default:** `false` + +- **`experimental.voice.activationMode`** (enum): + + - **Description:** How to trigger voice recording with the Space key. + - **Default:** `"push-to-talk"` + - **Values:** `"push-to-talk"`, `"toggle"` + +- **`experimental.voice.backend`** (enum): + + - **Description:** The backend to use for voice transcription. Note: When + using the Gemini Live backend, voice recordings are sent to Google Cloud for + transcription. + - **Default:** `"gemini-live"` + - **Values:** `"gemini-live"`, `"whisper"` + +- **`experimental.voice.whisperModel`** (enum): + + - **Description:** The Whisper model to use for local transcription. + - **Default:** `"ggml-base.en.bin"` + - **Values:** `"ggml-tiny.en.bin"`, `"ggml-base.en.bin"`, + `"ggml-large-v3-turbo-q5_0.bin"`, `"ggml-large-v3-turbo-q8_0.bin"` + +- **`experimental.voice.stopGracePeriodMs`** (number): + + - **Description:** How long to wait for final transcription after stopping + recording. + - **Default:** `4000` + +- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean): + + - **Description:** Enable non-interactive agent sessions. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.adk.agentSessionInteractiveEnabled`** (boolean): + + - **Description:** Enable the agent session implementation for the interactive + CLI. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.adk.agentSessionSubagentEnabled`** (boolean): + + - **Description:** Route subagent invocations through the AgentSession + protocol instead of legacy executors. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.enableAgents`** (boolean): + + - **Description:** Enable local and remote subagents. + - **Default:** `true` + - **Requires restart:** Yes + +- **`experimental.worktrees`** (boolean): + + - **Description:** Enable automated Git worktree management for parallel work. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.extensionManagement`** (boolean): + + - **Description:** Enable extension management features. + - **Default:** `true` + - **Requires restart:** Yes + +- **`experimental.extensionConfig`** (boolean): + + - **Description:** Enable requesting and fetching of extension settings. + - **Default:** `true` + - **Requires restart:** Yes + +- **`experimental.extensionRegistry`** (boolean): + + - **Description:** Enable extension registry explore UI. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.extensionRegistryURI`** (string): + + - **Description:** The URI (web URL or local file path) of the extension + registry. + - **Default:** `"https://geminicli.com/extensions.json"` + - **Requires restart:** Yes + +- **`experimental.extensionReloading`** (boolean): + + - **Description:** Enables extension loading/unloading within the CLI session. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.useOSC52Paste`** (boolean): + + - **Description:** Use OSC 52 for pasting. This may be more robust than the + default system when using remote terminal sessions (if your terminal is + configured to allow it). + - **Default:** `false` + +- **`experimental.useOSC52Copy`** (boolean): + + - **Description:** Use OSC 52 for copying. This may be more robust than the + default system when using remote terminal sessions (if your terminal is + configured to allow it). + - **Default:** `false` + +- **`experimental.taskTracker`** (boolean): + + - **Description:** Enable task tracker tools. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.modelSteering`** (boolean): + + - **Description:** Enable model steering (user hints) to guide the model + during tool execution. + - **Default:** `false` + +- **`experimental.directWebFetch`** (boolean): + + - **Description:** Enable web fetch behavior that bypasses LLM summarization. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.dynamicModelConfiguration`** (boolean): + + - **Description:** Enable dynamic model configuration (definitions, + resolutions, and chains) via settings. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.enabled`** (boolean): + + - **Description:** Enable the Gemma Model Router (experimental). Requires a + local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.autoStartServer`** (boolean): + + - **Description:** Automatically start the LiteRT-LM server when Gemini CLI + starts and the Gemma router is enabled. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.binaryPath`** (string): + + - **Description:** Custom path to the LiteRT-LM binary. Leave empty to use the + default location (~/.gemini/bin/litert/). + - **Default:** `""` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.host`** (string): + + - **Description:** The host of the classifier. + - **Default:** `"http://localhost:9379"` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.model`** (string): + + - **Description:** The model to use for the classifier. Only tested on + `gemma3-1b-gpu-custom`. + - **Default:** `"gemma3-1b-gpu-custom"` + - **Requires restart:** Yes + +- **`experimental.stressTestProfile`** (boolean): + + - **Description:** Significantly lowers token limits to force early garbage + collection and distillation for testing purposes. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.autoMemory`** (boolean): + + - **Description:** Automatically extract memory patches and skills from past + sessions in the background. Every change is written as a unified diff + `.patch` file under `/.inbox//` and held for review + in /memory inbox; nothing is applied until you approve it. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.generalistProfile`** (boolean): + + - **Description:** Suitable for general coding and software development tasks. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.powerUserProfile`** (boolean): + + - **Description:** Less cache friendly version of the generalist profile. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.contextManagement`** (boolean): + + - **Description:** Enable logic for context management. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.topicUpdateNarration`** (boolean): + - **Description:** Deprecated: Use general.topicUpdateNarration instead. + - **Default:** `false` + +#### `skills` + +- **`skills.enabled`** (boolean): + + - **Description:** Enable Agent Skills. + - **Default:** `true` + - **Requires restart:** Yes + +- **`skills.disabled`** (array): + - **Description:** List of disabled skills. + - **Default:** `[]` + - **Requires restart:** Yes + +#### `hooksConfig` + +- **`hooksConfig.enabled`** (boolean): + + - **Description:** Canonical toggle for the hooks system. When disabled, no + hooks will be executed. + - **Default:** `true` + - **Requires restart:** Yes + +- **`hooksConfig.disabled`** (array): + + - **Description:** List of hook names (commands) that should be disabled. + Hooks in this list will not execute even if configured. + - **Default:** `[]` + +- **`hooksConfig.notifications`** (boolean): + - **Description:** Show visual indicators when hooks are executing. + - **Default:** `true` + +#### `hooks` + +- **`hooks.BeforeTool`** (array): + + - **Description:** Hooks that execute before tool execution. Can intercept, + validate, or modify tool calls. + - **Default:** `[]` + +- **`hooks.AfterTool`** (array): + + - **Description:** Hooks that execute after tool execution. Can process + results, log outputs, or trigger follow-up actions. + - **Default:** `[]` + +- **`hooks.BeforeAgent`** (array): + + - **Description:** Hooks that execute before agent loop starts. Can set up + context or initialize resources. + - **Default:** `[]` + +- **`hooks.AfterAgent`** (array): + + - **Description:** Hooks that execute after agent loop completes. Can perform + cleanup or summarize results. + - **Default:** `[]` + +- **`hooks.Notification`** (array): + + - **Description:** Hooks that execute on notification events (errors, + warnings, info). Can log or alert on specific conditions. + - **Default:** `[]` + +- **`hooks.SessionStart`** (array): + + - **Description:** Hooks that execute when a session starts. Can initialize + session-specific resources or state. + - **Default:** `[]` + +- **`hooks.SessionEnd`** (array): + + - **Description:** Hooks that execute when a session ends. Can perform cleanup + or persist session data. + - **Default:** `[]` + +- **`hooks.PreCompress`** (array): + + - **Description:** Hooks that execute before chat history compression. Can + back up or analyze conversation before compression. + - **Default:** `[]` + +- **`hooks.BeforeModel`** (array): + + - **Description:** Hooks that execute before LLM requests. Can modify prompts, + inject context, or control model parameters. + - **Default:** `[]` + +- **`hooks.AfterModel`** (array): + + - **Description:** Hooks that execute after LLM responses. Can process + outputs, extract information, or log interactions. + - **Default:** `[]` + +- **`hooks.BeforeToolSelection`** (array): + - **Description:** Hooks that execute before tool selection. Can filter or + prioritize available tools dynamically. + - **Default:** `[]` + +#### `contextManagement` + +- **`contextManagement.historyWindow.maxTokens`** (number): + + - **Description:** The number of tokens to allow before triggering + compression. + - **Default:** `150000` + - **Requires restart:** Yes + +- **`contextManagement.historyWindow.retainedTokens`** (number): + + - **Description:** The number of tokens to always retain. + - **Default:** `40000` + - **Requires restart:** Yes + +- **`contextManagement.messageLimits.normalMaxTokens`** (number): + + - **Description:** The target number of tokens to budget for a normal + conversation turn. + - **Default:** `2500` + - **Requires restart:** Yes + +- **`contextManagement.messageLimits.retainedMaxTokens`** (number): + + - **Description:** The maximum number of tokens a single conversation turn can + consume before truncation. + - **Default:** `12000` + - **Requires restart:** Yes + +- **`contextManagement.messageLimits.normalizationHeadRatio`** (number): + + - **Description:** The ratio of tokens to retain from the beginning of a + truncated message (0.0 to 1.0). + - **Default:** `0.25` + - **Requires restart:** Yes + +- **`contextManagement.tools.distillation.maxOutputTokens`** (number): + + - **Description:** Maximum tokens to show to the model when truncating large + tool outputs. + - **Default:** `10000` + - **Requires restart:** Yes + +- **`contextManagement.tools.distillation.summarizationThresholdTokens`** + (number): + + - **Description:** Threshold above which truncated tool outputs will be + summarized by an LLM. + - **Default:** `20000` + - **Requires restart:** Yes + +- **`contextManagement.tools.outputMasking.protectionThresholdTokens`** + (number): + + - **Description:** Minimum number of tokens to protect from masking (most + recent tool outputs). + - **Default:** `50000` + - **Requires restart:** Yes + +- **`contextManagement.tools.outputMasking.minPrunableThresholdTokens`** + (number): + + - **Description:** Minimum prunable tokens required to trigger a masking pass. + - **Default:** `30000` + - **Requires restart:** Yes + +- **`contextManagement.tools.outputMasking.protectLatestTurn`** (boolean): + - **Description:** Ensures the absolute latest turn is never masked, + regardless of token count. + - **Default:** `true` + - **Requires restart:** Yes + +#### `admin` + +- **`admin.secureModeEnabled`** (boolean): + + - **Description:** If true, disallows YOLO mode and "Always allow" options + from being used. + - **Default:** `false` + +- **`admin.extensions.enabled`** (boolean): + + - **Description:** If false, disallows extensions from being installed or + used. + - **Default:** `true` + +- **`admin.mcp.enabled`** (boolean): + + - **Description:** If false, disallows MCP servers from being used. + - **Default:** `true` + +- **`admin.mcp.config`** (object): + + - **Description:** Admin-configured MCP servers (allowlist). + - **Default:** `{}` + +- **`admin.mcp.requiredConfig`** (object): + + - **Description:** Admin-required MCP servers that are always injected. + - **Default:** `{}` + +- **`admin.skills.enabled`** (boolean): + - **Description:** If false, disallows agent skills from being used. + - **Default:** `true` + + +#### `mcpServers` + +Configures connections to one or more Model-Context Protocol (MCP) servers for +discovering and using custom tools. Gemini CLI attempts to connect to each +configured MCP server to discover available tools. Every discovered tool is +prepended with the `mcp_` prefix and its server alias to form a fully qualified +name (FQN) (for example, `mcp_serverAlias_actualToolName`) to avoid conflicts. +Note that the system might strip certain schema properties from MCP tool +definitions for compatibility. At least one of `command`, `url`, or `httpUrl` +must be provided. If multiple are specified, the order of precedence is +`httpUrl`, then `url`, then `command`. + + +> [!WARNING] +> Avoid using underscores (`_`) in your server aliases (for example, use +> `my-server` instead of `my_server`). The underlying policy engine parses Fully +> Qualified Names (`mcp_server_tool`) using the first underscore after the +> `mcp_` prefix. An underscore in your server alias will cause the parser to +> misidentify the server name, which can cause security policies to fail +> silently. + +- **`mcpServers.`** (object): The server parameters for the named + server. + - `command` (string, optional): The command to execute to start the MCP server + via standard I/O. + - `args` (array of strings, optional): Arguments to pass to the command. + - `env` (object, optional): Environment variables to set for the server + process. + - `cwd` (string, optional): The working directory in which to start the + server. + - `url` (string, optional): The URL of an MCP server that uses Server-Sent + Events (SSE) for communication. + - `httpUrl` (string, optional): The URL of an MCP server that uses streamable + HTTP for communication. + - `headers` (object, optional): A map of HTTP headers to send with requests to + `url` or `httpUrl`. + - `timeout` (number, optional): Timeout in milliseconds for requests to this + MCP server. + - `trust` (boolean, optional): Trust this server and bypass all tool call + confirmations. + - `description` (string, optional): A brief description of the server, which + may be used for display purposes. + - `includeTools` (array of strings, optional): List of tool names to include + from this MCP server. When specified, only the tools listed here will be + available from this server (allowlist behavior). If not specified, all tools + from the server are enabled by default. + - `excludeTools` (array of strings, optional): List of tool names to exclude + from this MCP server. Tools listed here will not be available to the model, + even if they are exposed by the server. **Note:** `excludeTools` takes + precedence over `includeTools` - if a tool is in both lists, it will be + excluded. + +#### `telemetry` + +Configures logging and metrics collection for Gemini CLI. For more information, +see [Telemetry](../cli/telemetry.md). + +- **Properties:** + - **`enabled`** (boolean): Whether or not telemetry is enabled. + - **`traces`** (boolean): Whether detailed traces with large attributes (like + tool outputs and file reads) are captured. Defaults to `false`. + - **`target`** (string): The destination for collected telemetry. Supported + values are `local` and `gcp`. + - **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter. + - **`otlpProtocol`** (string): The protocol for the OTLP Exporter (`grpc` or + `http`). + - **`logPrompts`** (boolean): Whether or not to include the content of user + prompts in the logs. + - **`outfile`** (string): The file to write telemetry to when `target` is + `local`. + - **`useCollector`** (boolean): Whether to use an external OTLP collector. + +### Example `settings.json` + +Here is an example of a `settings.json` file with the nested structure, new as +of v0.3.0: + +```json +{ + "general": { + "vimMode": true, + "preferredEditor": "code", + "sessionRetention": { + "enabled": true, + "maxAge": "30d", + "maxCount": 100 + } + }, + "ui": { + "theme": "GitHub", + "hideBanner": true, + "hideTips": false, + "customWittyPhrases": [ + "You forget a thousand things every day. Make sure this is one of ’em", + "Connecting to AGI" + ] + }, + "tools": { + "sandbox": "docker", + "discoveryCommand": "bin/get_tools", + "callCommand": "bin/call_tool", + "exclude": ["write_file"] + }, + "mcpServers": { + "mainServer": { + "command": "bin/mcp_server.py" + }, + "anotherServer": { + "command": "node", + "args": ["mcp_server.js", "--verbose"] + } + }, + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "http://localhost:4317", + "logPrompts": true + }, + "privacy": { + "usageStatisticsEnabled": true + }, + "model": { + "name": "gemini-1.5-pro-latest", + "maxSessionTurns": 10, + "summarizeToolOutput": { + "run_shell_command": { + "tokenBudget": 100 + } + } + }, + "context": { + "fileName": ["CONTEXT.md", "GEMINI.md"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadFromIncludeDirectories": true, + "fileFiltering": { + "respectGitIgnore": false + } + }, + "advanced": { + "excludedEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + } +} +``` + +## Shell history + +The CLI keeps a history of shell commands you run. To avoid conflicts between +different projects, this history is stored in a project-specific directory +within your user's home folder. + +- **Location:** `~/.gemini/tmp//shell_history` + - `` is a unique identifier generated from your project's root + path. + - The history is stored in a file named `shell_history`. + +## Environment variables and `.env` files + +Environment variables are a common way to configure applications, especially for +sensitive information like API keys or for settings that might change between +environments. For authentication setup, see the +[Authentication documentation](../get-started/authentication.mdx) which covers +all available authentication methods. + +The CLI automatically loads environment variables from an `.env` file. The +loading order is: + +1. `.env` file in the current working directory. +2. If not found, it searches upwards in parent directories until it finds an + `.env` file or reaches the project root (identified by a `.git` folder) or + the home directory. +3. If still not found, it looks for `~/.env` (in the user's home directory). + +**Environment variable exclusion:** Some environment variables (like `DEBUG` and +`DEBUG_MODE`) are automatically excluded from being loaded from project `.env` +files to prevent interference with gemini-cli behavior. Variables from +`.gemini/.env` files are never excluded. You can customize this behavior using +the `advanced.excludedEnvVars` setting in your `settings.json` file. + +- **`GEMINI_API_KEY`**: + - Your API key for the Gemini API. + - One of several available + [authentication methods](../get-started/authentication.mdx). + - Set this in your shell profile (for example, `~/.bashrc`, `~/.zshrc`) or an + `.env` file. +- **`GEMINI_MODEL`**: + - Specifies the default Gemini model to use. + - Overrides the hardcoded default + - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell: + `$env:GEMINI_MODEL="gemini-3-flash-preview"`) +- **`GEMINI_CLI_TRUST_WORKSPACE`**: + - If set to `"true"`, trusts the current workspace for the duration of the + session, bypassing the folder trust check. + - Useful for headless environments (for example, CI/CD pipelines). +- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**: + - Overrides the default location for the `trustedFolders.json` file. + - Useful if you want to store this configuration in a custom location instead + of the default `~/.gemini/`. +- **`GEMINI_CLI_IDE_PID`**: + - Manually specifies the PID of the IDE process to use for integration. This + is useful when running Gemini CLI in a standalone terminal while still + wanting to associate it with a specific IDE instance. + - Overrides the automatic IDE detection logic. +- **`GEMINI_CLI_HOME`**: + - Specifies the root directory for Gemini CLI's user-level configuration and + storage. + - By default, this is the user's system home directory. The CLI will create a + `.gemini` folder inside this directory. + - Useful for shared compute environments or keeping CLI state isolated. + - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows + PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`) +- **`GEMINI_CLI_SURFACE`**: + - Specifies a custom label to include in the `User-Agent` header for API + traffic reporting. + - This is useful for tracking specific internal tools or distribution + channels. + - Example: `export GEMINI_CLI_SURFACE="my-custom-tool"` (Windows PowerShell: + `$env:GEMINI_CLI_SURFACE="my-custom-tool"`) +- **`GOOGLE_API_KEY`**: + - Your Google Cloud API key. + - Required for using Vertex AI in express mode. + - Ensure you have the necessary permissions. + - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"` (Windows PowerShell: + `$env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`). +- **`GOOGLE_CLOUD_PROJECT`**: + - Your Google Cloud Project ID. + - Required for using Code Assist or Vertex AI. + - If using Vertex AI, ensure you have the necessary permissions in this + project. + - **Cloud Shell note:** When running in a Cloud Shell environment, this + variable defaults to a special project allocated for Cloud Shell users. If + you have `GOOGLE_CLOUD_PROJECT` set in your global environment in Cloud + Shell, it will be overridden by this default. To use a different project in + Cloud Shell, you must define `GOOGLE_CLOUD_PROJECT` in a `.env` file. + - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows + PowerShell: `$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). +- **`GOOGLE_APPLICATION_CREDENTIALS`** (string): + - **Description:** The path to your Google Application Credentials JSON file. + - **Example:** + `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"` + (Windows PowerShell: + `$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\credentials.json"`) +- **`GOOGLE_GENAI_API_VERSION`**: + - Specifies the API version to use for Gemini API requests. + - When set, overrides the default API version used by the SDK. + - Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell: + `$env:GOOGLE_GENAI_API_VERSION="v1"`) +- **`GOOGLE_GEMINI_BASE_URL`**: + - Overrides the default base URL for Gemini API requests (when using + `gemini-api-key` authentication). + - Must be a valid URL. For security, it must use HTTPS unless pointing to + `localhost` (or `127.0.0.1` / `[::1]`). + - Example: `export GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"` (Windows + PowerShell: `$env:GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"`) +- **`GOOGLE_VERTEX_BASE_URL`**: + - Overrides the default base URL for Vertex AI API requests (when using + `vertex-ai` authentication). + - Must be a valid URL. For security, it must use HTTPS unless pointing to + `localhost` (or `127.0.0.1` / `[::1]`). + - Example: `export GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"` + (Windows PowerShell: + `$env:GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`) +- **`OTLP_GOOGLE_CLOUD_PROJECT`**: + - Your Google Cloud Project ID for Telemetry in Google Cloud + - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows + PowerShell: `$env:OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). +- **`GEMINI_TELEMETRY_ENABLED`**: + - Set to `true` or `1` to enable telemetry. Any other value is treated as + disabling it. + - Overrides the `telemetry.enabled` setting. +- **`GEMINI_TELEMETRY_TRACES_ENABLED`**: + - Set to `true` or `1` to enable detailed tracing with large attributes. Any + other value is treated as disabling it. + - Overrides the `telemetry.traces` setting. +- **`GEMINI_TELEMETRY_TARGET`**: + - Sets the telemetry target (`local` or `gcp`). + - Overrides the `telemetry.target` setting. +- **`GEMINI_TELEMETRY_OTLP_ENDPOINT`**: + - Sets the OTLP endpoint for telemetry. + - Overrides the `telemetry.otlpEndpoint` setting. +- **`GEMINI_TELEMETRY_OTLP_PROTOCOL`**: + - Sets the OTLP protocol (`grpc` or `http`). + - Overrides the `telemetry.otlpProtocol` setting. +- **`GEMINI_TELEMETRY_LOG_PROMPTS`**: + - Set to `true` or `1` to enable or disable logging of user prompts. Any other + value is treated as disabling it. + - Overrides the `telemetry.logPrompts` setting. +- **`GEMINI_TELEMETRY_OUTFILE`**: + - Sets the file path to write telemetry to when the target is `local`. + - Overrides the `telemetry.outfile` setting. +- **`GEMINI_TELEMETRY_USE_COLLECTOR`**: + - Set to `true` or `1` to enable or disable using an external OTLP collector. + Any other value is treated as disabling it. + - Overrides the `telemetry.useCollector` setting. +- **`GOOGLE_CLOUD_LOCATION`**: + - Your Google Cloud Project Location (for example, us-central1). + - Required for using Vertex AI in non-express mode. + - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"` (Windows + PowerShell: `$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`). +- **`GEMINI_SANDBOX`**: + - Alternative to the `sandbox` setting in `settings.json`. + - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. +- **`GEMINI_SYSTEM_MD`**: + - Replaces the built‑in system prompt with content from a Markdown file. + - `true`/`1`: Use project default path `./.gemini/system.md`. + - Any other string: Treat as a path (relative/absolute supported, `~` + expands). + - `false`/`0` or unset: Use the built‑in prompt. See + [System Prompt Override](../cli/system-prompt.md). +- **`GEMINI_WRITE_SYSTEM_MD`**: + - Writes the current built‑in system prompt to a file for review. + - `true`/`1`: Write to `./.gemini/system.md`. Otherwise treat the value as a + path. + - Run the CLI once with this set to generate the file. +- **`SEATBELT_PROFILE`** (macOS specific): + - Switches the Seatbelt (`sandbox-exec`) profile on macOS. + - `permissive-open`: (Default) Denies operations by default, confining writes + to the project folder (and a few other folders, see + `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) while allowing + broad file reads and network access. + - `restrictive-open`: Declines operations by default, allows network. + - `strict-open`: Restricts both reads and writes to the working directory, + allows network. + - `strict-proxied`: Same as `strict-open` but routes network through proxy. + - ``: Uses a custom profile. To define a custom profile, create + a file named `sandbox-macos-.sb` in your project's `.gemini/` + directory (for example, `my-project/.gemini/sandbox-macos-custom.sb`). +- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI + itself): + - Set to `true` or `1` to enable verbose debug logging, which can be helpful + for troubleshooting. + - **Note:** These variables are automatically excluded from project `.env` + files by default to prevent interference with gemini-cli behavior. Use + `.gemini/.env` files if you need to set these for gemini-cli specifically. +- **`NO_COLOR`**: + - Set to any value to disable all color output in the CLI. +- **`CLI_TITLE`**: + - Set to a string to customize the title of the CLI. +- **`CODE_ASSIST_ENDPOINT`**: + - Specifies the endpoint for the code assist server. + - This is useful for development and testing. + +### Environment variable redaction + +To prevent accidental leakage of sensitive information, Gemini CLI automatically +redacts potential secrets from environment variables when executing tools (such +as shell commands). This "best effort" redaction applies to variables inherited +from the system or loaded from `.env` files. + +**Default Redaction Rules:** + +- **By Name:** Variables are redacted if their names contain sensitive terms + like `TOKEN`, `SECRET`, `PASSWORD`, `KEY`, `AUTH`, `CREDENTIAL`, `PRIVATE`, or + `CERT`. +- **By Value:** Variables are redacted if their values match known secret + patterns, such as: + - Private keys (RSA, OpenSSH, PGP, etc.) + - Certificates + - URLs containing credentials + - API keys and tokens (GitHub, Google, AWS, Stripe, Slack, etc.) +- **Specific Blocklist:** Certain variables like `CLIENT_ID`, `DB_URI`, + `DATABASE_URL`, and `CONNECTION_STRING` are always redacted by default. + +**Allowlist (Never Redacted):** + +- Common system variables (for example, `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, + `LANG`). +- Variables starting with `GEMINI_CLI_`. +- GitHub Action specific variables. + +**Configuration:** + +You can customize this behavior in your `settings.json` file: + +- **`security.allowedEnvironmentVariables`**: A list of variable names to + _never_ redact, even if they match sensitive patterns. +- **`security.blockedEnvironmentVariables`**: A list of variable names to + _always_ redact, even if they don't match sensitive patterns. + +```json +{ + "security": { + "allowedEnvironmentVariables": ["MY_PUBLIC_KEY", "NOT_A_SECRET_TOKEN"], + "blockedEnvironmentVariables": ["INTERNAL_IP_ADDRESS"] + } +} +``` + +## Command-line arguments + +Arguments passed directly when running the CLI can override other configurations +for that specific session. + +- **`--acp`**: + - Starts the agent in Agent Communication Protocol (ACP) mode. +- **`--allowed-mcp-server-names`**: + - A comma-separated list of MCP server names to allow for the session. +- **`--allowed-tools `**: + - A comma-separated list of tool names that will bypass the confirmation + dialog. + - Example: `gemini --allowed-tools "ShellTool(git status)"` +- **`--approval-mode `**: + - Sets the approval mode for tool calls. Available modes: + - `default`: Prompt for approval on each tool call (default behavior) + - `auto_edit`: Automatically approve edit tools (replace, write_file) while + prompting for others + - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) + - `plan`: Read-only mode for tool calls (requires experimental planning to + be enabled). + > **Note:** This mode is currently under development and not yet fully + > functional. + - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of + `--yolo` for the new unified approach. + - Example: `gemini --approval-mode auto_edit` +- **`--debug`** (**`-d`**): + - Enables debug mode for this session, providing more verbose output. Open the + debug console with F12 to see the additional logging. +- **`--delete-session `**: + - Delete a specific chat session by its index number or full session UUID. + - Use `--list-sessions` first to see available sessions, their indices, and + UUIDs. + - Example: `gemini --delete-session 3` or + `gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890` +- **`--extensions `** (**`-e `**): + - Specifies a list of extensions to use for the session. If not provided, all + available extensions are used. + - Use the special term `gemini -e none` to disable all extensions. + - Example: `gemini -e my-extension -e my-other-extension` +- **`--fake-responses`**: + - Path to a file with fake model responses for testing. +- **`--help`** (or **`-h`**): + - Displays help information about command-line arguments. +- **`--include-directories `**: + - Includes additional directories in the workspace for multi-directory + support. + - Can be specified multiple times or as comma-separated values. + - 5 directories can be added at maximum. + - Example: `--include-directories /path/to/project1,/path/to/project2` or + `--include-directories /path/to/project1 --include-directories /path/to/project2` +- **`--list-extensions`** (**`-l`**): + - Lists all available extensions and exits. +- **`--list-sessions`**: + - List all available chat sessions for the current project and exit. + - Shows session indices, dates, message counts, and preview of first user + message. + - Example: `gemini --list-sessions` +- **`--model `** (**`-m `**): + - Specifies the Gemini model to use for this session. + - Example: `npm start -- --model gemini-3-pro-preview` +- **`--output-format `**: + - **Description:** Specifies the format of the CLI output for non-interactive + mode. + - **Values:** + - `text`: (Default) The standard human-readable output. + - `json`: A machine-readable JSON output. + - `stream-json`: A streaming JSON output that emits real-time events. + - **Note:** For structured output and scripting, use the + `--output-format json` or `--output-format stream-json` flag. +- **`--prompt `** (**`-p `**): + - Used to pass a prompt directly to the command. This invokes Gemini CLI in a + non-interactive mode. +- **`--prompt-interactive `** (**`-i `**): + - Starts an interactive session with the provided prompt as the initial input. + - The prompt is processed within the interactive session, not before it. + - Cannot be used when piping input from stdin. + - Example: `gemini -i "explain this code"` +- **`--record-responses`**: + - Path to a file to record model responses for testing. +- **`--resume [session_id]`** (**`-r [session_id]`**): + - Resume a previous chat session. Use "latest" for the most recent session, + provide a session index number, or provide a full session UUID. + - If no session_id is provided, defaults to "latest". + - Example: `gemini --resume 5` or `gemini --resume latest` or + `gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume` + - See [Session Management](../cli/session-management.md) for more details. +- **`--sandbox`** (**`-s`**): + - Enables sandbox mode for this session. +- **`--screen-reader`**: + - Enables screen reader mode, which adjusts the TUI for better compatibility + with screen readers. +- **`--version`**: + - Displays the version of the CLI. +- **`--yolo`**: + - Enables YOLO mode, which automatically approves all tool calls. + +## Context files (hierarchical instructional context) + +While not strictly configuration for the CLI's _behavior_, context files +(defaulting to `GEMINI.md` but configurable via the `context.fileName` setting) +are crucial for configuring the _instructional context_ (also referred to as +"memory") provided to the Gemini model. This powerful feature lets you give +project-specific instructions, coding style guides, or any relevant background +information to the AI, making its responses more tailored and accurate to your +needs. The CLI includes UI elements, such as an indicator in the footer showing +the number of loaded context files, to keep you informed about the active +context. + +- **Purpose:** These Markdown files contain instructions, guidelines, or context + that you want the Gemini model to be aware of during your interactions. The + system is designed to manage this instructional context hierarchically. + +### Example context file content (for example, `GEMINI.md`) + +Here's a conceptual example of what a context file at the root of a TypeScript +project might contain: + +```markdown +# Project: My Awesome TypeScript Library + +## General Instructions: + +- When generating new TypeScript code, follow the existing coding style. +- Ensure all new functions and classes have JSDoc comments. +- Prefer functional programming paradigms where appropriate. +- All code should be compatible with TypeScript 5.0 and Node.js 20+. + +## Coding Style: + +- Use 2 spaces for indentation. +- Interface names should be prefixed with `I` (for example, `IUserService`). +- Private class members should be prefixed with an underscore (`_`). +- Always use strict equality (`===` and `!==`). + +## Specific Component: `src/api/client.ts` + +- This file handles all outbound API requests. +- When adding new API call functions, ensure they include robust error handling + and logging. +- Use the existing `fetchWithRetry` utility for all GET requests. + +## Regarding Dependencies: + +- Avoid introducing new external dependencies unless absolutely necessary. +- If a new dependency is required, state the reason. +``` + +This example demonstrates how you can provide general project context, specific +coding conventions, and even notes about particular files or components. The +more relevant and precise your context files are, the better the AI can assist +you. Project-specific context files are highly encouraged to establish +conventions and context. + +- **Hierarchical loading and precedence:** The CLI implements a sophisticated + hierarchical memory system by loading context files (for example, `GEMINI.md`) + from several locations. Content from files lower in this list (more specific) + typically overrides or supplements content from files higher up (more + general). The exact concatenation order and final context can be inspected + using the `/memory show` command. The typical loading order is: + 1. **Global context file:** + - Location: `~/.gemini/` (for example, + `~/.gemini/GEMINI.md` in your user home directory). + - Scope: Provides default instructions for all your projects. + 2. **Project root and ancestors context files:** + - Location: The CLI searches for the configured context file in the + current working directory and then in each parent directory up to either + the project root (identified by a `.git` folder) or your home directory. + - Scope: Provides context relevant to the entire project or a significant + portion of it. + 3. **Sub-directory context files (contextual/local):** + - Location: The CLI also scans for the configured context file in + subdirectories _below_ the current working directory (respecting common + ignore patterns like `node_modules`, `.git`, etc.). The breadth of this + search is limited to 200 directories by default, but can be configured + with the `context.discoveryMaxDirs` setting in your `settings.json` + file. + - Scope: Allows for highly specific instructions relevant to a particular + component, module, or subsection of your project. +- **Concatenation and UI indication:** The contents of all found context files + are concatenated (with separators indicating their origin and path) and + provided as part of the system prompt to the Gemini model. The CLI footer + displays the count of loaded context files, giving you a quick visual cue + about the active instructional context. +- **Importing content:** You can modularize your context files by importing + other Markdown files using the `@path/to/file.md` syntax. For more details, + see the [Memory Import Processor documentation](./memport.md). +- **Commands for memory management:** + - Use `/memory refresh` to force a re-scan and reload of all context files + from all configured locations. This updates the AI's instructional context. + - Use `/memory show` to display the combined instructional context currently + loaded, allowing you to verify the hierarchy and content being used by the + AI. + - See the [Commands documentation](./commands.md#memory) for full details on + the `/memory` command and its sub-commands (`show` and `reload`). + +By understanding and utilizing these configuration layers and the hierarchical +nature of context files, you can effectively manage the AI's memory and tailor +Gemini CLI's responses to your specific needs and projects. + +## Sandboxing + +Gemini CLI can execute potentially unsafe operations (like shell commands and +file modifications) within a sandboxed environment to protect your system. + +Sandboxing is disabled by default, but you can enable it in a few ways: + +- Using `--sandbox` or `-s` flag. +- Setting `GEMINI_SANDBOX` environment variable. +- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. + +By default, it uses a pre-built `gemini-cli-sandbox` Docker image. + +For project-specific sandboxing needs, you can create a custom Dockerfile at +`.gemini/sandbox.Dockerfile` in your project's root directory. This Dockerfile +can be based on the base sandbox image: + +```dockerfile +FROM gemini-cli-sandbox + +# Add your custom dependencies or configurations here. +# Note: The base image runs as the non-root 'node' user. +# You must switch to 'root' to install system packages. +# For example: +# USER root +# RUN apt-get update && apt-get install -y some-package +# USER node +# COPY ./my-config /app/my-config +``` + +When `.gemini/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` +environment variable when running Gemini CLI to automatically build the custom +sandbox image: + +```bash +BUILD_SANDBOX=1 gemini -s +``` + +Building a custom sandbox with `BUILD_SANDBOX` is only supported when running +Gemini CLI from source. If you installed the CLI with npm, build the Docker +image separately and reference that image in your sandbox configuration. + +## Usage statistics + +To help us improve Gemini CLI, we collect anonymized usage statistics. This data +helps us understand how the CLI is used, identify common issues, and prioritize +new features. + +**What we collect:** + +- **Tool calls:** We log the names of the tools that are called, whether they + succeed or fail, and how long they take to execute. We do not collect the + arguments passed to the tools or any data returned by them. +- **API requests:** We log the Gemini model used for each request, the duration + of the request, and whether it was successful. We do not collect the content + of the prompts or responses. +- **Session information:** We collect information about the configuration of the + CLI, such as the enabled tools and the approval mode. + +**What we DON'T collect:** + +- **Personally identifiable information (PII):** We do not collect any personal + information, such as your name, email address, or API keys. +- **Prompt and response content:** We do not log the content of your prompts or + the responses from the Gemini model. +- **File content:** We do not log the content of any files that are read or + written by the CLI. + +**How to opt out:** + +You can opt out of usage statistics collection at any time by setting the +`usageStatisticsEnabled` property to `false` under the `privacy` category in +your `settings.json` file: + +```json +{ + "privacy": { + "usageStatisticsEnabled": false + } +} +``` diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md new file mode 100644 index 0000000000000000000000000000000000000000..54bc8cc61f535449d6ffcc6b7ec728bb18e62899 --- /dev/null +++ b/docs/reference/keyboard-shortcuts.md @@ -0,0 +1,360 @@ +# Gemini CLI keyboard shortcuts + +Gemini CLI ships with a set of default keyboard shortcuts for editing input, +navigating history, and controlling the UI. Use this reference to learn the +available combinations. + + + +#### Basic Controls + +| Command | Action | Keys | +| --------------- | --------------------------------------------------------------- | ------------------- | +| `basic.confirm` | Confirm the current selection or choice. | `Enter` | +| `basic.cancel` | Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | +| `basic.quit` | Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | +| `basic.exit` | Exit the CLI when the input buffer is empty. | `Ctrl+D` | + +#### Cursor Movement + +| Command | Action | Keys | +| ------------------ | ------------------------------------------- | ------------------------------------------ | +| `cursor.home` | Move the cursor to the start of the line. | `Ctrl+A`
`Home` | +| `cursor.end` | Move the cursor to the end of the line. | `Ctrl+E`
`End` | +| `cursor.up` | Move the cursor up one line. | `Up` | +| `cursor.down` | Move the cursor down one line. | `Down` | +| `cursor.left` | Move the cursor one character to the left. | `Left` | +| `cursor.right` | Move the cursor one character to the right. | `Right`
`Ctrl+F` | +| `cursor.wordLeft` | Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | +| `cursor.wordRight` | Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | + +#### Editing + +| Command | Action | Keys | +| ---------------------- | ------------------------------------------------ | -------------------------------------------------------- | +| `edit.deleteRightAll` | Delete from the cursor to the end of the line. | `Ctrl+K` | +| `edit.deleteLeftAll` | Delete from the cursor to the start of the line. | `Ctrl+U` | +| `edit.clear` | Clear all text in the input field. | `Ctrl+C` | +| `edit.deleteWordLeft` | Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | +| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | +| `edit.deleteLeft` | Delete the character to the left. | `Backspace`
`Ctrl+H` | +| `edit.deleteRight` | Delete the character to the right. | `Delete`
`Ctrl+D` | +| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z`
`Alt+Z`
`Cmd/Win+Z` | +| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | + +#### Scrolling + +| Command | Action | Keys | +| ----------------- | ------------------------ | ----------------------------- | +| `scroll.up` | Scroll content up. | `Shift+Up` | +| `scroll.down` | Scroll content down. | `Shift+Down` | +| `scroll.home` | Scroll to the top. | `Ctrl+Home`
`Shift+Home` | +| `scroll.end` | Scroll to the bottom. | `Ctrl+End`
`Shift+End` | +| `scroll.pageUp` | Scroll up by one page. | `Page Up` | +| `scroll.pageDown` | Scroll down by one page. | `Page Down` | + +#### History & Search + +| Command | Action | Keys | +| ----------------------- | -------------------------------------------- | -------- | +| `history.previous` | Show the previous entry in history. | `Ctrl+P` | +| `history.next` | Show the next entry in history. | `Ctrl+N` | +| `history.search.start` | Start reverse search through history. | `Ctrl+R` | +| `history.search.submit` | Submit the selected reverse-search match. | `Enter` | +| `history.search.accept` | Accept a suggestion while reverse searching. | `Tab` | + +#### Navigation + +| Command | Action | Keys | +| --------------------- | -------------------------------------------------- | --------------- | +| `nav.up` | Move selection up in lists. | `Up` | +| `nav.down` | Move selection down in lists. | `Down` | +| `nav.dialog.up` | Move up within dialog options. | `Up`
`K` | +| `nav.dialog.down` | Move down within dialog options. | `Down`
`J` | +| `nav.dialog.next` | Move to the next item or question in a dialog. | `Tab` | +| `nav.dialog.previous` | Move to the previous item or question in a dialog. | `Shift+Tab` | + +#### Suggestions & Completions + +| Command | Action | Keys | +| ----------------------- | --------------------------------------- | -------------------- | +| `suggest.accept` | Accept the inline suggestion. | `Tab`
`Enter` | +| `suggest.focusPrevious` | Move to the previous completion option. | `Up`
`Ctrl+P` | +| `suggest.focusNext` | Move to the next completion option. | `Down`
`Ctrl+N` | +| `suggest.expand` | Expand an inline suggestion. | `Right` | +| `suggest.collapse` | Collapse an inline suggestion. | `Left` | + +#### Text Input + +| Command | Action | Keys | +| ------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `input.submit` | Submit the current prompt. | `Enter` | +| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` | +| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+G`
`Ctrl+Shift+G` | +| `input.deprecatedOpenExternalEditor` | Deprecated command to open external editor. | `Ctrl+X` | +| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | + +#### App Controls + +| Command | Action | Keys | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `app.showErrorDetails` | Toggle the debug console for detailed error information. | `F12` | +| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` | +| `app.showIdeContextDetail` | Show IDE context details. | `F4` | +| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` | +| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` | +| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `Ctrl+S` | +| `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | +| `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | +| `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | +| `app.expandPaste` | Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` | +| `app.focusShellInput` | Move focus from Gemini to the active shell. | `Tab` | +| `app.unfocusShellInput` | Move focus from the shell back to Gemini. | `Shift+Tab` | +| `app.clearScreen` | Clear the terminal screen and redraw the UI. | `Ctrl+L` | +| `app.restart` | Restart the application. | `R`
`Shift+R` | +| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` | +| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` | +| `app.voiceModePTT` | Hold to speak in Voice Mode. | `Space` | + +#### Background Shell Controls + +| Command | Action | Keys | +| --------------------------- | ------------------------------------------------------------------ | ----------- | +| `background.escape` | Dismiss background shell list. | `Esc` | +| `background.select` | Confirm selection in background shell list. | `Enter` | +| `background.toggle` | Toggle current background shell visibility. | `Ctrl+B` | +| `background.toggleList` | Toggle background shell list. | `Ctrl+L` | +| `background.kill` | Kill the active background shell. | `Ctrl+K` | +| `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` | +| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | +| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | +| `app.dumpFrame` | Dump the current frame as a snapshot. | `F8` | +| `app.startRecording` | Start recording the session. | `F6` | +| `app.stopRecording` | Stop recording the session. | `F7` | + +#### Extension Controls + +| Command | Action | Keys | +| ------------------ | ------------------------------------------- | ---- | +| `extension.update` | Update the current extension if available. | `I` | +| `extension.link` | Link the current extension to a local path. | `L` | + + + +## Customizing Keybindings + +You can add alternative keybindings or remove default keybindings by creating a +`keybindings.json` file in your home gemini directory (typically +`~/.gemini/keybindings.json`). + +### Configuration Format + +The configuration uses a JSON array of objects, similar to VS Code's keybinding +schema. Each object must specify a `command` from the reference tables above and +a `key` combination. + +```json +[ + { + "command": "edit.clear", + "key": "cmd+l" + }, + { + // prefix "-" to unbind a key + "command": "-app.toggleYolo", + "key": "ctrl+y" + }, + { + "command": "input.submit", + "key": "ctrl+y" + }, + { + // multiple modifiers + "command": "cursor.right", + "key": "shift+alt+a" + }, + { + // Some mac keyboards send "Γ…" instead of "shift+option+a" + "command": "cursor.right", + "key": "Γ…" + }, + { + // some base keys have special multi-char names + "command": "cursor.right", + "key": "shift+pageup" + } +] +``` + +- **Unbinding** To remove an existing or default keybinding, prefix a minus sign + (`-`) to the `command` name. +- **No Auto-unbinding** The same key can be bound to multiple commands in + different contexts at the same time. Therefore, creating a binding does not + automatically unbind the key from other commands. +- **Explicit Modifiers**: Key matching is explicit. For example, a binding for + `ctrl+f` will only trigger on exactly `ctrl+f`, not `ctrl+shift+f` or + `alt+ctrl+f`. +- **Literal Characters**: Terminals often translate complex key combinations + (especially on macOS with the `Option` key) into special characters, losing + modifier and keystroke information along the way. For example,`shift+5` might + be sent as `%`. In these cases, you must bind to the literal character `%` as + bindings to `shift+5` will never fire. To see precisely what is being sent, + enable `Debug Keystroke Logging` and hit f12 to open the debug log console. +- **Key Modifiers**: The supported key modifiers are: + - `ctrl` + - `shift`, + - `alt` (synonyms: `opt`, `option`) + - `cmd` (synonym: `meta`) +- **Base Key**: The base key can be any single unicode code point or any of the + following special keys: + - **Navigation**: `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, + `pagedown` + - **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`, + `clear`, `insert`, `printscreen` + - **Toggles**: `capslock`, `numlock`, `scrolllock`, `pausebreak` + - **Function Keys**: `f1` through `f35` + - **Numpad**: `numpad0` through `numpad9`, `numpad_add`, `numpad_subtract`, + `numpad_multiply`, `numpad_divide`, `numpad_decimal`, `numpad_separator` + +## Additional context-specific shortcuts + +- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your + terminal isn't configured to send Meta with Option. +- `!` on an empty prompt: Enter or exit shell mode. +- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press + `Esc`, `Backspace`, any printable key, or a registered app hotkey to close it. + The panel also auto-hides while the agent is running/streaming or when + action-required dialogs are shown. Press `?` again to close the panel and + insert a `?` into the prompt. +- `Tab` + `Tab` (while typing in the prompt): Toggle between minimal and full UI + details when no completion/search interaction is active. The selected mode is + remembered for future sessions. Full UI remains the default on first run, and + single `Tab` keeps its existing completion/focus behavior. +- `Shift + Tab` (while typing in the prompt): Cycle approval modes: default, + auto-edit, and plan (skipped when agent is busy). +- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line + mode. +- `Esc` pressed twice quickly: Clear the input prompt if it is not empty, + otherwise browse and rewind previous interactions. +- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a + single-line input, navigate backward or forward through prompt history. +- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to + the numbered radio option and confirm when the full number is entered. +- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`) + inline when the cursor is over the placeholder. +- `Ctrl + X` (while a plan is presented): Open the plan in an external editor to + [collaboratively edit or comment](../cli/plan-mode.md#collaborative-plan-editing) + on the implementation strategy. +- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to + view full content inline. Double-click again to collapse. + +## Vi mode shortcuts + +When vim mode is enabled with `/vim` or `general.vimMode: true`, Gemini CLI +supports NORMAL and INSERT modes. + +### Mode switching + +| Action | Keys | +| -------------------------------------------- | --------- | +| Enter NORMAL mode from INSERT mode | `Esc` | +| Enter INSERT mode at the cursor | `i` | +| Enter INSERT mode after the cursor | `a` | +| Enter INSERT mode at the start of the line | `I` | +| Enter INSERT mode at the end of the line | `A` | +| Insert a new line below and switch to INSERT | `o` | +| Insert a new line above and switch to INSERT | `O` | +| Clear input in NORMAL mode | `Esc Esc` | + +### Navigation in NORMAL mode + +| Action | Keys | +| --------------------------------- | --------------- | +| Move left | `h` | +| Move down | `j` | +| Move up | `k` | +| Move right | `l` | +| Move to start of line | `0` | +| Move to first non-whitespace char | `^` | +| Move to end of line | `$` | +| Move forward by word | `w` | +| Move backward by word | `b` | +| Move to end of word | `e` | +| Move forward by WORD | `W` | +| Move backward by WORD | `B` | +| Move to end of WORD | `E` | +| Go to first line | `gg` | +| Go to last line | `G` | +| Go to line N | `N G` or `N gg` | + +Counts are supported for navigation commands. For example, `5j` moves down five +lines and `3w` moves forward three words. + +### Editing in NORMAL mode + +| Action | Keys | +| ------------------------------ | ----- | +| Delete character under cursor | `x` | +| Delete to end of line | `D` | +| Delete line | `dd` | +| Change to end of line | `C` | +| Change line | `cc` | +| Delete forward word | `dw` | +| Delete backward word | `db` | +| Delete to end of word | `de` | +| Delete forward WORD | `dW` | +| Delete backward WORD | `dB` | +| Delete to end of WORD | `dE` | +| Change forward word | `cw` | +| Change backward word | `cb` | +| Change to end of word | `ce` | +| Change forward WORD | `cW` | +| Change backward WORD | `cB` | +| Change to end of WORD | `cE` | +| Delete to start of line | `d0` | +| Delete to first non-whitespace | `d^` | +| Change to start of line | `c0` | +| Change to first non-whitespace | `c^` | +| Delete from first line to here | `dgg` | +| Delete from here to last line | `dG` | +| Change from first line to here | `cgg` | +| Change from here to last line | `cG` | +| Undo last change | `u` | +| Repeat last command | `.` | + +Counts are also supported for editing commands. For example, `3dd` deletes three +lines and `2cw` changes two words. + +### Find, replace, yank, and paste in NORMAL mode + +| Action | Keys | +| ----------------------------------------- | ----------- | +| Find next matching character | `f{char}` | +| Find previous matching character | `F{char}` | +| Move until before next matching character | `t{char}` | +| Move until after previous matching char | `T{char}` | +| Repeat latest character find | `;` | +| Repeat latest character find in reverse | `,` | +| Delete character before cursor | `X` | +| Toggle case under cursor | `~` | +| Replace character under cursor | `r{char}` | +| Yank line | `yy` | +| Yank to end of line | `Y` or `y$` | +| Yank word / WORD | `yw`, `yW` | +| Yank to end of word / WORD | `ye`, `yE` | +| Paste after cursor | `p` | +| Paste before cursor | `P` | + +Delete and change operators also compose with character-find motions, so +commands such as `dfx`, `dtx`, `cFx`, and `cTx` are supported. + +## Limitations + +- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal): + - `shift+enter` is only supported in version 1.25 and higher. + - `shift+tab` + [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314) + on Node 20 and earlier versions of Node 22. +- On macOS's [Terminal](): + - `shift+enter` is not supported. diff --git a/docs/reference/memport.md b/docs/reference/memport.md new file mode 100644 index 0000000000000000000000000000000000000000..a8c2da5a2d1ad648fe4e089fcdf56ed0c1cdea38 --- /dev/null +++ b/docs/reference/memport.md @@ -0,0 +1,245 @@ +# Memory Import Processor + +The Memory Import Processor is a feature that lets you modularize your GEMINI.md +files by importing content from other files using the `@file.md` syntax. + +## Overview + +This feature enables you to break down large GEMINI.md files into smaller, more +manageable components that can be reused across different contexts. The import +processor supports both relative and absolute paths, with built-in safety +features to prevent circular imports and ensure file access security. + +## Syntax + +Use the `@` symbol followed by the path to the file you want to import: + +```markdown +# Main GEMINI.md file + +This is the main content. + +@./components/instructions.md + +More content here. + +@./shared/configuration.md +``` + +## Supported path formats + +### Relative paths + +- `@./file.md` - Import from the same directory +- `@../file.md` - Import from parent directory +- `@./components/file.md` - Import from subdirectory + +### Absolute paths + +- `@/absolute/path/to/file.md` - Import using absolute path + +## Examples + +### Basic import + +```markdown +# My GEMINI.md + +Welcome to my project! + +@./get-started.md + +## Features + +@./features/overview.md +``` + +### Nested imports + +The imported files can themselves contain imports, creating a nested structure: + +```markdown +# main.md + +@./header.md @./content.md @./footer.md +``` + +```markdown +# header.md + +# Project Header + +@./shared/title.md +``` + +## Safety features + +### Circular import detection + +The processor automatically detects and prevents circular imports: + +```markdown +# file-a.md + +@./file-b.md +``` + +```markdown +# file-b.md + +@./file-a.md +``` + +### File access security + +The `validateImportPath` function ensures that imports are only allowed from +specified directories, preventing access to sensitive files outside the allowed +scope. + +### Maximum import depth + +To prevent infinite recursion, there's a configurable maximum import depth +(default: 5 levels). + +## Error handling + +### Missing files + +If a referenced file doesn't exist, the import will fail gracefully with an +error comment in the output. + +### File access errors + +Permission issues or other file system errors are handled gracefully with +appropriate error messages. + +## Code region detection + +The import processor uses the `marked` library to detect code blocks and inline +code spans, ensuring that `@` imports inside these regions are properly ignored. +This provides robust handling of nested code blocks and complex Markdown +structures. + +## Import tree structure + +The processor returns an import tree that shows the hierarchy of imported files, +similar to Claude's `/memory` feature. This helps users debug problems with +their GEMINI.md files by showing which files were read and their import +relationships. + +Example tree structure: + +``` +Memory Files + L project: GEMINI.md + L a.md + L b.md + L c.md + L d.md + L e.md + L f.md + L included.md +``` + +The tree preserves the order that files were imported and shows the complete +import chain for debugging purposes. + +## Comparison to Claude Code's `/memory` (`claude.md`) approach + +Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear +document by concatenating all included files, always marking file boundaries +with clear comments and path names. It does not explicitly present the import +hierarchy, but the LLM receives all file contents and paths, which is sufficient +for reconstructing the hierarchy if needed. + +> [!NOTE] The import tree is mainly for clarity during development and has +> limited relevance to LLM consumption. + +## API reference + +### `processImports(content, basePath, debugMode?, importState?)` + +Processes import statements in GEMINI.md content. + +**Parameters:** + +- `content` (string): The content to process for imports +- `basePath` (string): The directory path where the current file is located +- `debugMode` (boolean, optional): Whether to enable debug logging (default: + false) +- `importState` (ImportState, optional): State tracking for circular import + prevention + +**Returns:** Promise<ProcessImportsResult> - Object containing processed +content and import tree + +### `ProcessImportsResult` + +```typescript +interface ProcessImportsResult { + content: string; // The processed content with imports resolved + importTree: MemoryFile; // Tree structure showing the import hierarchy +} +``` + +### `MemoryFile` + +```typescript +interface MemoryFile { + path: string; // The file path + imports?: MemoryFile[]; // Direct imports, in the order they were imported +} +``` + +### `validateImportPath(importPath, basePath, allowedDirectories)` + +Validates import paths to ensure they are safe and within allowed directories. + +**Parameters:** + +- `importPath` (string): The import path to validate +- `basePath` (string): The base directory for resolving relative paths +- `allowedDirectories` (string[]): Array of allowed directory paths + +**Returns:** boolean - Whether the import path is valid + +### `findProjectRoot(startDir)` + +Finds the project root by searching for a `.git` directory upwards from the +given start directory. Implemented as an **async** function using non-blocking +file system APIs to avoid blocking the Node.js event loop. + +**Parameters:** + +- `startDir` (string): The directory to start searching from + +**Returns:** Promise<string> - The project root directory (or the start +directory if no `.git` is found) + +## Best Practices + +1. **Use descriptive file names** for imported components +2. **Keep imports shallow** - avoid deeply nested import chains +3. **Document your structure** - maintain a clear hierarchy of imported files +4. **Test your imports** - ensure all referenced files exist and are accessible +5. **Use relative paths** when possible for better portability + +## Troubleshooting + +### Common issues + +1. **Import not working**: Check that the file exists and the path is correct +2. **Circular import warnings**: Review your import structure for circular + references +3. **Permission errors**: Ensure the files are readable and within allowed + directories +4. **Path resolution issues**: Use absolute paths if relative paths aren't + resolving correctly + +### Debug mode + +Enable debug mode to see detailed logging of the import process: + +```typescript +const result = await processImports(content, basePath, true); +``` diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md new file mode 100644 index 0000000000000000000000000000000000000000..95e65b33bd47e342c644b059e2e7a7c27d171d85 --- /dev/null +++ b/docs/reference/policy-engine.md @@ -0,0 +1,499 @@ +# Policy engine + +Gemini CLI includes a powerful policy engine that provides fine-grained control +over tool execution. It allows users and administrators to define rules that +determine whether a tool call should be allowed, denied, or require user +confirmation. + +## Quick start + +To create your first policy: + +1. **Create the policy directory** if it doesn't exist: + + **macOS/Linux** + + ```bash + mkdir -p ~/.gemini/policies + ``` + + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\policies" + ``` + +2. **Create a new policy file** (for example, + `~/.gemini/policies/my-rules.toml`). You can use any filename ending in + `.toml`; all such files in this directory will be loaded and combined: + ```toml + [[rule]] + toolName = "run_shell_command" + commandPrefix = "rm -rf" + decision = "deny" + priority = 100 + ``` +3. **Run a command** that triggers the policy (for example, ask Gemini CLI to + `rm -rf /`). The tool will now be blocked automatically. + +## Core concepts + +The policy engine operates on a set of rules. Each rule is a combination of +conditions and a resulting decision. When a large language model wants to +execute a tool, the policy engine evaluates all rules to find the +highest-priority rule that matches the tool call. + +A rule consists of the following main components: + +- **Conditions**: Criteria that a tool call must meet for the rule to apply. + This can include the tool's name, the arguments provided to it, or the current + approval mode. +- **Decision**: The action to take if the rule matches (`allow`, `deny`, or + `ask_user`). +- **Priority**: A number that determines the rule's precedence. Higher numbers + win. + +For example, this rule will ask for user confirmation before executing any `git` +command. + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git" +decision = "ask_user" +priority = 100 +``` + +### Conditions + +Conditions are the criteria that a tool call must meet for a rule to apply. The +primary conditions are the tool's name and its arguments. + +#### Tool Name + +The `toolName` in the rule must match the name of the tool being called. For a +complete list of built-in tool names, see the +[Tools reference](/docs/reference/tools#available-tools). + +- **Wildcards**: You can use wildcards to match multiple tools. + - `*`: Matches **any tool** (built-in or MCP). + - `mcp_server_*`: Matches any tool from a specific MCP server. + - `mcp_*_toolName`: Matches a specific tool name across **all** MCP servers. + - `mcp_*`: Matches **any tool from any MCP server**. + +> **Recommendation:** While FQN wildcards are supported, the recommended +> approach for MCP tools is to use the `mcpName` field in your TOML rules. See +> [Special syntax for MCP tools](#special-syntax-for-mcp-tools). + +#### Arguments pattern + +If `argsPattern` is specified, the tool's arguments are converted to a stable +JSON string, which is then tested against the provided regular expression. If +the arguments don't match the pattern, the rule does not apply. For a list of +argument keys available for each tool, see the **Parameters** in the +[Tools reference](/docs/reference/tools#available-tools). + +#### Execution environment + +If `interactive` is specified, the rule will only apply if the CLI's execution +environment matches the specified boolean value: + +- `true`: The rule applies only in interactive mode. +- `false`: The rule applies only in non-interactive (headless) mode. + +If omitted, the rule applies to both interactive and non-interactive +environments. + +### Decisions + +There are three possible decisions a rule can enforce: + +- `allow`: The tool call is executed automatically without user interaction. +- `deny`: The tool call is blocked and is not executed. For global rules (those + without an `argsPattern`), tools that are denied are **completely excluded + from the model's memory**. This means the model will not even see the tool as + an option, which is more secure and saves context window space. +- `ask_user`: The user is prompted to approve or deny the tool call. (In + non-interactive mode, this is treated as `deny`.) + + +> [!NOTE] +> The `deny` decision is the recommended way to exclude tools. The +> legacy `tools.exclude` setting in `settings.json` is deprecated in favor of +> policy rules with a `deny` decision. + +### Priority system and tiers + +> [!WARNING] The **Workspace** tier (project-level policies) is currently +> non-functional. Defining policies in a workspace's `.gemini/policies` +> directory will not have any effect. See +> [issue #18186](https://github.com/google-gemini/gemini-cli/issues/18186). Use +> User or Admin policies instead. + +The policy engine uses a sophisticated priority system to resolve conflicts when +multiple rules match a single tool call. The core principle is simple: **the +rule with the highest priority wins**. + +To provide a clear hierarchy, policies are organized into three tiers. Each tier +has a designated number that forms the base of the final priority calculation. + +| Tier | Base | Description | +| :-------- | :--- | :-------------------------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with Gemini CLI. | +| Extension | 2 | Policies defined in extensions. | +| Workspace | 3 | **(Currently disabled)** Policies defined in the current workspace's configuration directory. | +| User | 4 | Custom policies defined by the user. | +| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). | + +Within a TOML policy file, you assign a priority value from **0 to 999**. The +engine transforms this into a final priority using the following formula: + +`final_priority = tier_base + (toml_priority / 1000)` + +This system guarantees that: + +- Admin policies always override User, Workspace, and Default policies (defined + in policy TOML files). +- User policies override Workspace and Default policies. +- Workspace policies override Default policies. +- You can still order rules within a single tier with fine-grained control. + +For example: + +- A `priority: 50` rule in a Default policy TOML becomes `1.050`. +- A `priority: 10` rule in a Workspace policy TOML becomes `2.010`. +- A `priority: 100` rule in a User policy TOML becomes `3.100`. +- A `priority: 20` rule in an Admin policy TOML becomes `4.020`. + +### Approval modes + +Approval modes allow the policy engine to apply different sets of rules based on +the CLI's operational mode. A rule in a TOML policy file can be associated with +one or more modes (for example, `yolo`, `autoEdit`, `plan`). The rule will only +be active if the CLI is running in one of its specified modes. If a rule has no +modes specified, it is always active. + +- `default`: The standard interactive mode where most write tools require + confirmation. +- `autoEdit`: Optimized for automated code editing; some write tools may be + auto-approved. +- `plan`: A strict, read-only mode for research and design. See + [Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies). +- `yolo`: A mode where all tools are auto-approved (use with extreme caution). + +To maintain the integrity of Plan Mode as a safe research environment, +persistent tool approvals are context-aware. When you select **"Allow for all +future sessions"**, the policy engine explicitly includes the current mode and +all more permissive modes in the hierarchy (`plan` < `default` < `autoEdit` < +`yolo`). + +- **Approvals in `plan` mode**: These represent an intentional choice to trust a + tool globally. The resulting rule explicitly includes all modes (`plan`, + `default`, `autoEdit`, and `yolo`). +- **Approvals in other modes**: These only apply to the current mode and those + more permissive. For example: + - An approval granted in **`default`** mode applies to `default`, `autoEdit`, + and `yolo`. + - An approval granted in **`autoEdit`** mode applies to `autoEdit` and `yolo`. + - An approval granted in **`yolo`** mode applies only to `yolo`. This ensures + that trust flows correctly to more permissive environments while maintaining + the safety of more restricted modes like `plan`. + +## Rule matching + +When a tool call is made, the engine checks it against all active rules, +starting from the highest priority. The first rule that matches determines the +outcome. + +A rule matches a tool call if all of its conditions are met: + +1. **Tool name**: The `toolName` in the TOML rule must match the name of the + tool being called. + - **Wildcards**: You can use wildcards like `*`, `mcp_server_*`, or + `mcp_*_toolName` to match multiple tools. See [Tool Name](#tool-name) for + details. +2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments + are converted to a stable JSON string, which is then tested against the + provided regular expression. If the arguments don't match the pattern, the + rule does not apply. + +## Configuration + +Policies are defined in `.toml` files. The CLI loads these files from Default, +User, and (if configured) Admin directories. + +### Policy locations + +| Tier | Type | Location | +| :------------ | :----- | :------------------------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Workspace** | Custom | **(Disabled)** `$WORKSPACE_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | + +#### System-wide policies (Admin) + +Administrators can enforce system-wide policies (Tier 4) that override all user +and default settings. These policies can be loaded from standard system +locations or supplemental paths. + +##### Standard Locations + +These are the default paths the CLI searches for admin policies: + +| OS | Policy Directory Path | +| :---------- | :------------------------------------------------ | +| **Linux** | `/etc/gemini-cli/policies` | +| **macOS** | `/Library/Application Support/GeminiCli/policies` | +| **Windows** | `C:\ProgramData\gemini-cli\policies` | + +##### Supplemental Admin Policies + +Administrators can also specify supplemental policy paths using: + +- The `--admin-policy` command-line flag. +- The `adminPolicyPaths` setting in a system settings file. + +These supplemental policies are assigned the same **Admin** tier (Base 4) as +policies in standard locations. + +**Security Guard**: Supplemental admin policies are **ignored** if any `.toml` +policy files are found in the standard system location. This prevents flag-based +overrides when a central system policy has already been established. + +#### Security Requirements + +To prevent privilege escalation, the CLI enforces strict security checks on the +**standard system policy directory**. If checks fail, the policies in that +directory are **ignored**. + +- **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group + or others (for example, `chmod 755`). +- **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) + must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a + security warning, use the folder properties to remove write permissions for + non-admin groups. You may need to "Disable inheritance" in Advanced Security + Settings. + + +> [!NOTE] +> Supplemental admin policies (provided via `--admin-policy` or +> `adminPolicyPaths` settings) are **NOT** subject to these strict ownership +> checks, as they are explicitly provided by the user or administrator in their +> current execution context. + +### TOML rule schema + +This section describes the fields available in a TOML policy rule. + +For valid built-in `toolName` values and their argument structures (used by +`argsPattern`), see the +[Tools reference](/docs/reference/tools#available-tools). + +```toml +[[rule]] +# A unique name for the tool, or an array of names. +toolName = "run_shell_command" + +# (Optional) The name of a subagent. If provided, the rule only applies to tool +# calls made by this specific subagent. +subagent = "codebase_investigator" + +# (Optional) The name of an MCP server. Can be combined with toolName +# to form a composite FQN internally like "mcp_mcpName_toolName". +mcpName = "my-custom-server" + +# (Optional) Metadata hints provided by the tool. A rule matches if all +# key-value pairs provided here are present in the tool's annotations. +toolAnnotations = { readOnlyHint = true } + +# (Optional) A regex to match against the tool's arguments. +argsPattern = '"command":"(git|npm)' + +# (Optional) A string or array of strings that a shell command must start with. +# This is syntactic sugar for `toolName = "run_shell_command"` and an +# `argsPattern`. +commandPrefix = "git" + +# (Optional) A regex to match against the entire shell command. +# This is also syntactic sugar for `toolName = "run_shell_command"`. +# Note: This pattern is tested against the JSON representation of the arguments +# (e.g., `{"command":""}`). Because it prepends `"command":"`, +# it effectively matches from the start of the command. +# Anchors like `^` or `$` apply to the full JSON string, +# so `^` should usually be avoided here. +# You cannot use commandPrefix and commandRegex in the same rule. +commandRegex = "git (commit|push)" + +# The decision to take. Must be "allow", "deny", or "ask_user". +decision = "ask_user" + +# The priority of the rule, from 0 to 999. +priority = 10 + +# (Optional) A custom message to display when a tool call is denied by this +# rule. This message is returned to the model and user, +# useful for explaining *why* it was denied. +denyMessage = "Deletion is permanent" + +# (Optional) An array of approval modes where this rule is active. +# If omitted or empty, the rule applies to all modes. +modes = ["default", "autoEdit", "yolo"] + +# (Optional) A boolean to restrict the rule to interactive (true) or +# non-interactive (false) environments. +# If omitted, the rule applies to both. +interactive = true + +# (Optional) If true, lets shell commands use redirection operators +# (>, >>, <, <<, <<<). By default, the policy engine asks for confirmation +# when redirection is detected, even if a rule matches the command. +# This permission is granular; it only applies to the specific rule it's +# defined in. In chained commands (e.g., cmd1 > file && cmd2), each +# individual command rule must permit redirection if it's used. +allowRedirection = true +``` + +### Using arrays (lists) + +To apply the same rule to multiple tools or command prefixes, you can provide an +array of strings for the `toolName` and `commandPrefix` fields. + +**Example:** + +This single rule will apply to both the `write_file` and `replace` tools. + +```toml +[[rule]] +toolName = ["write_file", "replace"] +decision = "ask_user" +priority = 10 +``` + +### Special syntax for `run_shell_command` + +To simplify writing policies for `run_shell_command`, you can use +`commandPrefix` or `commandRegex` instead of the more complex `argsPattern`. +These are policy-rule shorthands, not arguments of the `run_shell_command` tool +itself. For the tool's invocation arguments, see [Shell tool](/docs/tools/shell) +and [Tools reference](/docs/reference/tools#available-tools). + +- `commandPrefix`: Matches if the `command` argument starts with the given + string. +- `commandRegex`: Matches if the `command` argument matches the given regular + expression. + +**Example:** + +This rule will ask for user confirmation before executing any `git` command. + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git" +decision = "ask_user" +priority = 100 +``` + +### Special syntax for MCP tools + +You can create rules that target tools from Model Context Protocol (MCP) servers +using the `mcpName` field. **This is the recommended approach** for defining MCP +policies, as it is much more robust than manually writing Fully Qualified Names +(FQNs) or string wildcards. + + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (for example, use +> `my-server` rather than `my_server`). The policy parser splits Fully Qualified +> Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` +> prefix. If your server name contains an underscore, the parser will +> misinterpret the server identity, which can cause wildcard rules and security +> policies to fail silently. + +**1. Targeting a specific tool on a server** + +Combine `mcpName` and `toolName` to target a single operation. When using +`mcpName`, the `toolName` field should strictly be the simple name of the tool +(for example, `search`), **not** the Fully Qualified Name (for example, +`mcp_server_search`). + +```toml +# Allows the `search` tool on the `my-jira-server` MCP +[[rule]] +mcpName = "my-jira-server" +toolName = "search" +decision = "allow" +priority = 200 +``` + +**2. Targeting all tools on a specific server** + +Specify only the `mcpName` to apply a rule to every tool provided by that +server. + +**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). + +```toml +# Denies all tools from the `untrusted-server` MCP +[[rule]] +mcpName = "untrusted-server" +decision = "deny" +priority = 500 +denyMessage = "This server is not trusted by the admin." +``` + +**3. Targeting all MCP servers** + +Use `mcpName = "*"` to create a rule that applies to **all** tools from **any** +registered MCP server. This is useful for setting category-wide defaults. + +```toml +# Ask user for any tool call from any MCP server +[[rule]] +toolName = "*" +mcpName = "*" +decision = "ask_user" +priority = 10 +``` + +### Special syntax for subagents + +You can secure and govern subagents using standard policy rules by treating the +subagent's name as the `toolName`. + +When the main agent invokes a subagent (e.g., using the unified `invoke_agent` +tool), the Policy Engine automatically treats the target `agent_name` as a +virtual tool alias for rule matching. + +**Example:** + +This rule denies access to the `codebase_investigator` subagent. + +```toml +[[rule]] +toolName = "codebase_investigator" +decision = "deny" +priority = 500 +deny_message = "Deep codebase analysis is restricted for this session." +``` + +- **Backward Compatibility**: Any rules written targeting historical 1:1 + subagent tool names will continue to match transparently. +- **Context differentiation**: To create rules based on **who** is calling a + tool, use the `subagent` field instead. See + [TOML rule schema](#toml-rule-schema). + +## Default policies + +Gemini CLI ships with a set of default policies to provide a safe out-of-the-box +experience. + +- **Read-only tools** (like `read_file`, `glob`) are generally **allowed**. +- **Agent delegation** defaults to **`ask_user`** to ensure remote agents can + prompt for confirmation, but local sub-agent actions are executed silently and + checked individually. +- **Write tools** (like `write_file`, `run_shell_command`) default to + **`ask_user`**. +- In **`yolo`** mode, a high-priority rule allows all tools. +- In **`autoEdit`** mode, rules allow certain write operations to happen without + prompting. diff --git a/docs/reference/tools.md b/docs/reference/tools.md new file mode 100644 index 0000000000000000000000000000000000000000..f52b3b6644f94da2351169f4a66f1f1971354ab6 --- /dev/null +++ b/docs/reference/tools.md @@ -0,0 +1,222 @@ +# Tools reference + +Gemini CLI uses tools to interact with your local environment, access +information, and perform actions on your behalf. These tools extend the model's +capabilities beyond text generation, letting it read files, execute commands, +and search the web. + +## How to use Gemini CLI's tools + +Tools are generally invoked automatically by Gemini CLI when it needs to perform +an action. However, you can also trigger specific tools manually using shorthand +syntax. + +### Automatic execution and security + +When the model wants to use a tool, Gemini CLI evaluates the request against its +security policies. + +- **User confirmation:** You must manually approve tools that modify files or + execute shell commands (mutators). The CLI shows you a diff or the exact + command before you confirm. +- **Sandboxing:** You can run tool executions in secure, containerized + environments to isolate changes from your host system. For more details, see + the [Sandboxing](../cli/sandbox.md) guide. +- **Trusted folders:** You can configure which directories allow the model to + use system tools. For more details, see the + [Trusted folders](../cli/trusted-folders.md) guide. + +Review confirmation prompts carefully before allowing a tool to execute. + +### How to use manually-triggered tools + +You can directly trigger key tools using special syntax in your prompt: + +- **[File access](../tools/file-system.md#read_many_files) (`@`):** Use the `@` + symbol followed by a file or directory path to include its content in your + prompt. This triggers the `read_many_files` tool. +- **[Shell commands](../tools/shell.md) (`!`):** Use the `!` symbol followed by + a system command to execute it directly. This triggers the `run_shell_command` + tool. + +## How to manage tools + +Using built-in commands, you can inspect available tools and configure how they +behave. + +### Tool discovery + +Use the `/tools` command to see what tools are currently active in your session. + +- **`/tools`**: Lists all registered tools with their display names. +- **`/tools desc`**: Lists all tools with their full descriptions. + +This is especially useful for verifying that +[MCP servers](../tools/mcp-server.md) or custom tools are loaded correctly. + +### Tool configuration + +You can enable, disable, or configure specific tools in your settings. For +example, you can set a specific pager for shell commands or configure the +browser used for web searches. See the [Settings](../cli/settings.md) guide for +details. + +## Available tools + +The following sections list all available tools, categorized by their primary +function. For detailed parameter information, see the linked documentation for +each tool. + +### Execution + +| Tool | Kind | Description | +| :--------------------------------------- | :-------- | :----------------------------------------------------------------------------------------------------------------------- | +| [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation. | + +### File System + +| Tool | Kind | Description | +| :------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------- | +| [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace. | +| [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`. | +| [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path. | +| [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF. | +| [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt. | +| [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation. | +| [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation. | + +### Interaction + +| Tool | Kind | Description | +| :--------------------------------- | :------------ | :------------------------------------------------------------------------------------- | +| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. | +| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. | + +### Task Tracker (Experimental) + + +> [!NOTE] +> This is an experimental feature currently under active development. Enable via `experimental.taskTracker`. + +| Tool | Kind | Description | +| :---------------------------------------------- | :------ | :-------------------------------------------------------------------------- | +| [`tracker_create_task`](../tools/tracker.md) | `Other` | Creates a new task in the experimental tracker. | +| [`tracker_update_task`](../tools/tracker.md) | `Other` | Updates an existing task's status, description, or dependencies. | +| [`tracker_get_task`](../tools/tracker.md) | `Other` | Retrieves the full details of a specific task. | +| [`tracker_list_tasks`](../tools/tracker.md) | `Other` | Lists tasks in the tracker, optionally filtered by status, type, or parent. | +| [`tracker_add_dependency`](../tools/tracker.md) | `Other` | Adds a dependency between two tasks, ensuring topological execution. | +| [`tracker_visualize`](../tools/tracker.md) | `Other` | Renders an ASCII tree visualization of the current task graph. | + +### MCP + +| Tool | Kind | Description | +| :------------------------------------------------ | :------- | :--------------------------------------------------------------------- | +| [`list_mcp_resources`](../tools/mcp-resources.md) | `Search` | Lists all available resources exposed by connected MCP servers. | +| [`read_mcp_resource`](../tools/mcp-resources.md) | `Read` | Reads the content of a specific Model Context Protocol (MCP) resource. | + +### Memory + +| Tool | Kind | Description | +| :----------------------------------------------- | :------ | :----------------------------------------------------------------------------------- | +| [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise from the `.gemini/skills` directory. | +| [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation for accurate answers about its capabilities. | + +### Planning + +| Tool | Kind | Description | +| :---------------------------------------- | :----- | :--------------------------------------------------------------------------------------- | +| [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes. | +| [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation. | + +### System + +| Tool | Kind | Description | +| :-------------- | :------ | :----------------------------------------------------------------------------------------------------------------- | +| `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user. | + +### Task Tracking + +| Tool | Kind | Description | +| :----------------------- | :------ | :-------------------------------------------------------------------------- | +| `tracker_add_dependency` | `Think` | Adds a dependency between two existing tasks in the tracker. | +| `tracker_create_task` | `Think` | Creates a new task in the internal tracker to monitor progress. | +| `tracker_get_task` | `Think` | Retrieves the details and current status of a specific tracked task. | +| `tracker_list_tasks` | `Think` | Lists all tasks currently being tracked. | +| `tracker_update_task` | `Think` | Updates the status or details of an existing task. | +| `tracker_visualize` | `Think` | Generates a visual representation of the current task dependency graph. | +| `update_topic` | `Think` | Updates the current topic and status to keep the user informed of progress. | + +### Web + +| Tool | Kind | Description | +| :-------------------------------------------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. | +| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. Outbound requests are validated against private and reserved IP ranges. In Plan Mode, this tool requires explicit user confirmation. | + +### Tool argument keys + +When writing [`argsPattern`](./policy-engine.md#arguments-pattern) rules for the +[policy engine](./policy-engine.md), you need to know the JSON argument keys for +each tool. The following table lists the keys that appear in the JSON +representation of each tool's arguments. + +| Tool | JSON argument keys | +| :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run_shell_command` | `command`, `description`, `dir_path`, `is_background` | +| `glob` | `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` | +| `grep_search` | `pattern`, `dir_path`, `include_pattern`, `exclude_pattern`, `names_only`, `case_sensitive`, `fixed_strings`, `context`, `after`, `before`, `no_ignore`, `max_matches_per_file`, `total_max_matches` | +| `list_directory` | `dir_path`, `ignore`, `file_filtering_options` | +| `read_file` | `file_path`, `start_line`, `end_line` | +| `read_many_files` | `include`, `exclude`, `recursive`, `useDefaultExcludes` | +| `write_file` | `file_path`, `content` | +| `replace` | `file_path`, `old_string`, `new_string`, `instruction`, `allow_multiple` | +| `ask_user` | `questions` (array of `question`, `header`, `type`, `options`) | +| `write_todos` | `todos` (array of `description`, `status`) | +| `activate_skill` | `name` | +| `get_internal_docs` | `path` | +| `enter_plan_mode` | `reason` | +| `exit_plan_mode` | `plan_path` | +| `tracker_create_task` | `title`, `description`, `type` | +| `tracker_update_task` | `id`, `title`, `description`, `status`, `dependencies` | +| `tracker_get_task` | `id` | +| `tracker_list_tasks` | `status`, `type`, `parentId` | +| `tracker_add_dependency` | `taskId`, `dependencyId` | +| `tracker_visualize` | _(none)_ | +| `update_topic` | `title`, `summary`, `strategic_intent` | +| `google_web_search` | `query` | +| `web_fetch` | `prompt` | + +For example, to write a policy rule that blocks any `write_file` call targeting +a `.env` file, you would match against the `file_path` key: + +```toml +[[rule]] +toolName = "write_file" +argsPattern = '"file_path":".*\.env"' +decision = "deny" +priority = 100 +denyMessage = "Writing to .env files is not allowed." +``` + +For full argument descriptions and types, see the individual tool pages linked +in the [tables above](#available-tools). + +## Under the hood + +For developers, the tool system is designed to be extensible and robust. The +`ToolRegistry` class manages all available tools. + +You can extend Gemini CLI with custom tools by configuring +`tools.discoveryCommand` in your settings or by connecting to MCP servers. + + +> [!NOTE] +> For a deep dive into the internal Tool API and how to implement your +> own tools in the codebase, see the `packages/core/src/tools/` directory in +> GitHub. + +## Next steps + +- Learn how to [Set up an MCP server](../tools/mcp-server.md). +- Explore [Agent Skills](../cli/skills.md) for specialized expertise. +- See the [Command reference](./commands.md) for slash commands. diff --git a/docs/resources/faq.md b/docs/resources/faq.md new file mode 100644 index 0000000000000000000000000000000000000000..834eda02cecb31d4dbff17bedeaf27b24c512050 --- /dev/null +++ b/docs/resources/faq.md @@ -0,0 +1,188 @@ +# Frequently asked questions (FAQ) + +This page provides answers to common questions and solutions to frequent +problems encountered while using Gemini CLI. + +## General issues + +This section addresses common questions about Gemini CLI usage, security, and +troubleshooting general errors. + +### Why can't I use third-party software like Claude Code, OpenClaw, or OpenCode with Gemini CLI? + +Using third-party software, tools, or services to harvest or piggyback on Gemini +CLI's OAuth authentication to access our backend services is a direct violation +of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our +intended authentication and security structures, and such actions may be grounds +for immediate suspension or termination of your account. If you would like to +use a third-party coding agent with Gemini, the supported and secure method is +to use a Vertex AI or Google AI Studio API key. + +### Why am I getting an `API error: 429 - Resource exhausted`? + +This error indicates that you have exceeded your API request limit. The Gemini +API has rate limits to prevent abuse and ensure fair usage. + +To resolve this, you can: + +- **Check your usage:** Review your API usage in the Google AI Studio or your + Google Cloud project dashboard. +- **Optimize your prompts:** If you are making many requests in a short period, + try to batch your prompts or introduce delays between requests. +- **Request a quota increase:** If you consistently need a higher limit, you can + request a quota increase from Google. + +### Why am I getting an `ERR_REQUIRE_ESM` error when running `npm run start`? + +This error typically occurs in Node.js projects when there is a mismatch between +CommonJS and ES Modules. + +This is often due to a misconfiguration in your `package.json` or +`tsconfig.json`. Ensure that: + +1. Your `package.json` has `"type": "module"`. +2. Your `tsconfig.json` has `"module": "NodeNext"` or a compatible setting in + the `compilerOptions`. + +If the problem persists, try deleting your `node_modules` directory and +`package-lock.json` file, and then run `npm install` again. + +### Why don't I see cached token counts in my stats output? + +Cached token information is only displayed when cached tokens are being used. +This feature is available for API key users (Gemini API key or Google Cloud +Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts +like Google Gmail or Google Workspace, respectively). This is because the Gemini +Code Assist API does not support cached content creation. You can still view +your total token usage using the `/stats` command in Gemini CLI. + +## Installation and updates + +### How do I check which version of Gemini CLI I'm currently running? + +You can check your current Gemini CLI version using one of these methods: + +- Run `gemini --version` or `gemini -v` from your terminal +- Check the globally installed version using your package manager: + - npm: `npm list -g @google/gemini-cli` + - pnpm: `pnpm list -g @google/gemini-cli` + - yarn: `yarn global list @google/gemini-cli` + - bun: `bun pm ls -g @google/gemini-cli` + - homebrew: `brew list --versions gemini-cli` +- Inside an active Gemini CLI session, use the `/about` command + +### How do I update Gemini CLI to the latest version? + +If you installed it globally via `npm`, update it using the command +`npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull +the latest changes from the repository, and then rebuild using the command +`npm run build`. + +## Platform-specific issues + +### Why does the CLI crash on Windows when I run a command like `chmod +x`? + +Commands like `chmod` are specific to Unix-like operating systems (Linux, +macOS). They are not available on Windows by default. + +To resolve this, you can: + +- **Use Windows-equivalent commands:** Instead of `chmod`, you can use `icacls` + to modify file permissions on Windows. +- **Use a compatibility layer:** Tools like Git Bash or Windows Subsystem for + Linux (WSL) provide a Unix-like environment on Windows where these commands + will work. + +## Configuration + +### How do I configure my `GOOGLE_CLOUD_PROJECT`? + +You can configure your Google Cloud Project ID using an environment variable. + +Set the `GOOGLE_CLOUD_PROJECT` environment variable in your shell: + +**macOS/Linux** + +```bash +export GOOGLE_CLOUD_PROJECT="your-project-id" +``` + +**Windows (PowerShell)** + +```powershell +$env:GOOGLE_CLOUD_PROJECT="your-project-id" +``` + +To make this setting permanent, add this line to your shell's startup file (for +example, `~/.bashrc`, `~/.zshrc`). + +### What is the best way to store my API keys securely? + +Exposing API keys in scripts or checking them into source control is a security +risk. + +To store your API keys securely, you can: + +- **Use a `.env` file:** Create a `.env` file in your project's `.gemini` + directory (`.gemini/.env`) and store your keys there. Gemini CLI will + automatically load these variables. +- **Use your system's keyring:** For the most secure storage, use your operating + system's secret management tool (like macOS Keychain, Windows Credential + Manager, or a secret manager on Linux). You can then have your scripts or + environment load the key from the secure storage at runtime. + +### Where are Gemini CLI configuration and settings files stored? + +Gemini CLI configuration is stored in two `settings.json` files: + +1. In your home directory: `~/.gemini/settings.json`. +2. In your project's root directory: `./.gemini/settings.json`. + +Refer to [Gemini CLI Configuration](../reference/configuration.md) for more +details. + +## Google AI Pro/Ultra and subscription FAQs + +### Where can I learn more about my Google AI Pro or Google AI Ultra subscription? + +To learn more about your Google AI Pro or Google AI Ultra subscription, visit +**Manage subscription** in your [subscription settings](https://one.google.com). + +### How do I know if I have higher limits for Google AI Pro or Ultra? + +If you're subscribed to Google AI Pro or Ultra, you automatically have higher +limits to Gemini Code Assist and Gemini CLI. These are shared across Gemini CLI +and agent mode in the IDE. You can confirm you have higher limits by checking if +you are still subscribed to Google AI Pro or Ultra in your +[subscription settings](https://one.google.com). + +### What is the privacy policy for using Gemini Code Assist or Gemini CLI if I've subscribed to Google AI Pro or Ultra? + +To learn more about your privacy policy and terms of service governed by your +subscription, visit +[Gemini Code Assist: Terms of Service and Privacy Policies](https://developers.google.com/gemini-code-assist/resources/privacy-notices). + +### I've upgraded to Google AI Pro or Ultra but it still says I am hitting quota limits. Is this a bug? + +The higher limits in your Google AI Pro or Ultra subscription are for Gemini 2.5 +across both Gemini 2.5 Pro and Flash. They are shared quota across Gemini CLI +and agent mode in Gemini Code Assist IDE extensions. You can learn more about +quota limits for Gemini CLI, Gemini Code Assist and agent mode in Gemini Code +Assist at +[Quotas and limits](https://developers.google.com/gemini-code-assist/resources/quotas). + +### If I upgrade to higher limits for Gemini CLI and Gemini Code Assist by purchasing a Google AI Pro or Ultra subscription, will Gemini start using my data to improve its machine learning models? + +Google does not use your data to improve Google's machine learning models if you +purchase a paid plan. Note: If you decide to remain on the free version of +Gemini Code Assist, Gemini Code Assist for individuals, you can also opt out of +using your data to improve Google's machine learning models. See the +[Gemini Code Assist for individuals privacy notice](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) +for more information. + +## Not seeing your question? + +Search the +[Gemini CLI Q&A discussions on GitHub](https://github.com/google-gemini/gemini-cli/discussions/categories/q-a) +or +[start a new discussion on GitHub](https://github.com/google-gemini/gemini-cli/discussions/new?category=q-a) diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md new file mode 100644 index 0000000000000000000000000000000000000000..b3921e38c1dafa72f9ecbfeb80b7235f3bee6a2f --- /dev/null +++ b/docs/resources/quota-and-pricing.md @@ -0,0 +1,214 @@ +# Gemini CLI: Quotas and pricing + +Gemini CLI offers a generous free tier that covers many individual developers' +use cases. For enterprise or professional usage, or if you need increased quota, +several options are available depending on your authentication account type. + +For a high-level comparison of available subscriptions and to select the right +quota for your needs, see the [Plans page](https://geminicli.com/plans/). + +## Overview + +This article outlines the specific quotas and pricing applicable to Gemini CLI +when using different authentication methods. + +The following table summarizes the available quotas and their respective limits: + +| Authentication method | Tier / Subscription | Maximum requests per user per day | +| :-------------------- | :------------------------------ | :-------------------------------- | +| **Google account** | Gemini Code Assist (Individual) | 1,000 requests | +| | Google AI Pro | 1,500 requests | +| | Google AI Ultra | 2,000 requests | +| **Gemini API key** | Free tier (Unpaid) | 250 requests | +| | Pay-as-you-go (Paid) | Varies | +| **Vertex AI** | Express mode (Free) | Varies | +| | Pay-as-you-go (Paid) | Varies | +| **Google Workspace** | Code Assist Standard | 1,500 requests | +| | Code Assist Enterprise | 2,000 requests | +| | Workspace AI Ultra | 2,000 requests | + +Generally, there are three categories to choose from: + +- Free Usage: Ideal for experimentation and light use. +- Paid Tier (fixed price): For individual developers or enterprises who need + more generous daily quotas and predictable costs. +- Pay-As-You-Go: The most flexible option for professional use, long-running + tasks, or when you need full control over your usage. + +Requests are limited per user per minute and are subject to the availability of +the service in times of high demand. + +## Free usage + +Access to Gemini CLI begins with a generous free tier, perfect for +experimentation and light use. + +Your free usage is governed by the following limits, which depend on your +authorization type. + +### Log in with Google (Gemini Code Assist for individuals) + +For users who authenticate by using their Google account to access Gemini Code +Assist for individuals. This includes: + +- 1000 maximum model requests / user / day +- Model requests will be made across the Gemini model family as determined by + Gemini CLI. + +Learn more at +[Gemini Code Assist for Individuals Limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). + +### Log in with Gemini API Key (unpaid) + +If you are using a Gemini API key, you can also benefit from a free tier. This +includes: + +- 250 maximum model requests / user / day +- Model requests to Flash model only. + +Learn more at +[Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits). + +### Log in with Vertex AI (Express Mode) + +Vertex AI offers an Express Mode without the need to enable billing. This +includes: + +- 90 days before you need to enable billing. +- Quotas and models are specific to your account and their limits vary. + +Learn more at +[Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas). + +## Paid tier: Higher limits for a fixed cost + +If you use up your initial number of requests, you can continue to benefit from +Gemini CLI by upgrading to one of the following subscriptions: + +### Individuals + +These tiers apply when you sign in with a personal account. To verify whether +you're on a personal account, visit +[Google One](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0): + +- If you are on a personal account, you will see your personal dashboard. +- If you are not on a personal account, you will see: "You're currently signed + in to your Google Workspace Account." + +**Supported tiers:** _- Tiers not listed above, including Google AI Plus, are +not supported._ + +- [Google AI Pro and AI Ultra](https://gemini.google/subscriptions/). This is + recommended for individual developers. Quotas and pricing are based on a fixed + price subscription. + + For predictable costs, you can log in with Google. + + Learn more at + [Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas) + +### Through your organization + +These tiers are applicable when you are signing in with a Google Workspace +account. + +- To verify your account type, visit + [the Google One page](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0). +- You are on a workspace account if you see the message "You're currently signed + in to your Google Workspace Account". + +**Supported tiers:** _- Tiers not listed above, including Workspace AI +Standard/PlusΒ and AI Expanded, are not supported._ + +- [Workspace AI Ultra Access](https://workspace.google.com/products/ai-ultra/). +- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview). + + Quotas and pricing are based on a fixed price subscription with assigned + license seats. For predictable costs, you can sign in with Google. + + This includes the following request limits: + + - Gemini Code Assist Standard edition: + - 1500 maximum model requests / user / day + - Gemini Code Assist Enterprise edition: + - 2000 maximum model requests / user / day + - Model requests will be made across the Gemini model family as determined by + Gemini CLI. + + [Learn more about Gemini Code Assist license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). + +## Pay as you go + +If you hit your daily request limits or exhaust your Gemini Pro quota even after +upgrading, the most flexible solution is to switch to a pay-as-you-go model, +where you pay for the specific amount of processing you use. This is the +recommended path for uninterrupted access. + +To do this, log in using a Gemini API key or Vertex AI. + +### Vertex AI (regular mode) + +An enterprise-grade platform for building, deploying, and managing AI models, +including Gemini. It offers enhanced security, data governance, and integration +with other Google Cloud services. + +- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned + throughput. +- Cost: Based on model and token usage. + +Learn more at +[Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota) +and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing). + +### Gemini API key + +Ideal for developers who want to quickly build applications with the Gemini +models. This is the most direct way to use the models. + +- Quota: Varies by pricing tier. +- Cost: Varies by pricing tier and model/token usage. + +Learn more at +[Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits), +[Gemini API Pricing](https://ai.google.dev/gemini-api/docs/pricing) + +It’s important to highlight that when using an API key, you pay per token/call. +This can be more expensive for many small calls with few tokens, but it's the +only way to ensure your workflow isn't interrupted by reaching a limit on your +quota. + +## Gemini for workspace plans + +These plans currently apply only to the use of Gemini web-based products +provided by Google-based experiences (for example, the Gemini web app or the +Flow video editor). These plans do not apply to the API usage which powers the +Gemini CLI. Supporting these plans is under active consideration for future +support. + +## Check usage and limits + +You can check your current token usage and applicable limits using the +`/stats model` command. This command provides a snapshot of your current +session's token usage, as well as information about the limits associated with +your current quota. + +For more information on the `/stats` command and its subcommands, see the +[Command Reference](../reference/commands.md#stats). + +A summary of model usage is also presented on exit at the end of a session. + +## Tips to avoid high costs + +When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected +costs. + +- **Be selective with suggestions**: Before accepting a suggestion, especially + for a computationally intensive task like refactoring a large codebase, + consider if it's the most cost-effective approach. +- **Use precise prompts**: You are paying per call, so think about the most + efficient way to get your desired result. A well-crafted prompt can often get + you the answer you need in a single call, rather than multiple back-and-forth + interactions. +- **Monitor your usage**: Use the `/stats model` command to track your token + usage during a session. This can help you stay aware of your spending in real + time. diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md new file mode 100644 index 0000000000000000000000000000000000000000..06966138894068be5c3dcffc65f5147ad74081a7 --- /dev/null +++ b/docs/resources/tos-privacy.md @@ -0,0 +1,104 @@ +# Gemini CLI: License, Terms of Service, and Privacy Notices + +Gemini CLI is an open-source tool that lets you interact with Google's powerful +AI services directly from your command-line interface. Gemini CLI software is +licensed under the +[Apache 2.0 license](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE). +When you use Gemini CLI to access or use Google’s services, the Terms of Service +and Privacy Notices applicable to those services apply to such access and use. + +Directly accessing the services powering Gemini CLI (for example, the Gemini +Code Assist service) using third-party software, tools, or services (for +example, using OpenClaw with Gemini CLI OAuth) is a violation of applicable +terms and policies. Such actions may be grounds for suspension or termination of +your account. + +Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy +Policy. + + +> [!NOTE] +> See [quotas and pricing](quota-and-pricing.md) for the quota and +> pricing details that apply to your usage of Gemini CLI. + +## Supported authentication methods + +Your authentication method refers to the method you use to log into and access +Google’s services with Gemini CLI. Supported authentication methods include: + +- Logging in with your Google account to Gemini Code Assist. +- Using an API key with Gemini Developer API. +- Using an API key with Vertex AI GenAI API. + +The Terms of Service and Privacy Notices applicable to the aforementioned Google +services are set forth in the table below. + +If you log in with your Google account and you do not already have a Gemini Code +Assist account associated with your Google account, you will be directed to the +sign up flow for Gemini Code Assist for individuals. If your Google account is +managed by your organization, your administrator may not permit access to Gemini +Code Assist for individuals. See the +[Gemini Code Assist for individuals FAQs](https://developers.google.com/gemini-code-assist/resources/faqs) +for further information. + +| Authentication Method | Service(s) | Terms of Service | Privacy Notice | +| :----------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------- | +| Google Account | Gemini Code Assist services | [Terms of Service](https://developers.google.com/gemini-code-assist/resources/privacy-notices) | [Privacy Notices](https://developers.google.com/gemini-code-assist/resources/privacy-notices) | +| Gemini Developer API Key | Gemini API - Unpaid Services | [Gemini API Terms of Service - Unpaid Services](https://ai.google.dev/gemini-api/terms#unpaid-services) | [Google Privacy Policy](https://policies.google.com/privacy) | +| Gemini Developer API Key | Gemini API - Paid Services | [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) | [Google Privacy Policy](https://policies.google.com/privacy) | +| Vertex AI GenAI API Key | Vertex AI GenAI API | [Google Cloud Platform Terms of Service](https://cloud.google.com/terms/service-terms/) | [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) | + +## 1. If you have signed in with your Google account to Gemini Code Assist + +For users who use their Google account to access +[Gemini Code Assist](https://codeassist.google), these Terms of Service and +Privacy Notice documents apply: + +- Gemini Code Assist for individuals: + [Google Terms of Service](https://policies.google.com/terms) and + [Gemini Code Assist for individuals Privacy Notice](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals). +- Gemini Code Assist with Google AI Pro or Ultra subscription: + [Google Terms of Service](https://policies.google.com/terms), + [Google One Additional Terms of Service](https://one.google.com/terms-of-service) + and [Google Privacy Policy\*](https://policies.google.com/privacy). +- Gemini Code Assist Standard and Enterprise editions: + [Google Cloud Platform Terms of Service](https://cloud.google.com/terms) and + [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice). + +_\* If your account is also associated with an active subscription to Gemini +Code Assist Standard or Enterprise edition, the terms and privacy policy of +Gemini Code Assist Standard or Enterprise edition will apply to all your use of +Gemini Code Assist._ + +## 2. If you have signed in with a Gemini API key to the Gemini Developer API + +If you are using a Gemini API key for authentication with the +[Gemini Developer API](https://ai.google.dev/gemini-api/docs), these Terms of +Service and Privacy Notice documents apply: + +- Terms of Service: Your use of Gemini CLI is governed by the + [Gemini API Terms of Service](https://ai.google.dev/gemini-api/terms). These + terms may differ depending on whether you are using an unpaid or paid service: + - For unpaid services, refer to the + [Gemini API Terms of Service - Unpaid Services](https://ai.google.dev/gemini-api/terms#unpaid-services). + - For paid services, refer to the + [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services). +- Privacy Notice: The collection and use of your data is described in the + [Google Privacy Policy](https://policies.google.com/privacy). + +## 3. If you have signed in with a Gemini API key to the Vertex AI GenAI API + +If you are using a Gemini API key for authentication with a +[Vertex AI GenAI API](https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest) +backend, these Terms of Service and Privacy Notice documents apply: + +- Terms of Service: Your use of Gemini CLI is governed by the + [Google Cloud Platform Service Terms](https://cloud.google.com/terms/service-terms/). +- Privacy Notice: The collection and use of your data is described in the + [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice). + +## Usage statistics opt-out + +You may opt-out from sending Gemini CLI Usage Statistics to Google by following +the instructions available here: +[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics). diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md new file mode 100644 index 0000000000000000000000000000000000000000..a5580dd0bbb0a5b46cfeb56c09922971ff1310ae --- /dev/null +++ b/docs/resources/troubleshooting.md @@ -0,0 +1,206 @@ +# Troubleshooting guide + +This guide provides solutions to common issues and debugging tips, including +topics on: + +- Authentication or login errors +- Frequently asked questions (FAQs) +- Debugging tips +- Existing GitHub Issues similar to yours or creating new Issues + +## Authentication or login errors + +- **Error: + `You must be a named user on your organization's Gemini Code Assist Standard edition subscription to use this service. Please contact your administrator to request an entitlement to Gemini Code Assist Standard edition.`** + + - **Cause:** This error might occur if Gemini CLI detects the + `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` environment variable is + defined. Setting these variables forces an organization subscription check. + This might be an issue if you are using an individual Google account not + linked to an organizational subscription. + + - **Solution:** + + - **Individual Users:** Unset the `GOOGLE_CLOUD_PROJECT` and + `GOOGLE_CLOUD_PROJECT_ID` environment variables. Check and remove these + variables from your shell configuration files (for example, `.bashrc`, + `.zshrc`) and any `.env` files. If this doesn't resolve the issue, try + using a different Google account. + + - **Organizational Users:** Contact your Google Cloud administrator to be + added to your organization's Gemini Code Assist subscription. + +- **Error: + `Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`** + + - **Cause:** Gemini CLI does not currently support your location. For a full + list of supported locations, see the following pages: + - Gemini Code Assist for individuals: + [Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) + +- **Error: `Failed to sign in. Message: Request contains an invalid argument`** + + - **Cause:** Users with Google Workspace accounts or Google Cloud accounts + associated with their Gmail accounts may not be able to activate the free + tier of the Google Code Assist plan. + - **Solution:** For Google Cloud accounts, you can work around this by setting + `GOOGLE_CLOUD_PROJECT` to your project ID. Alternatively, you can obtain the + Gemini API key from + [Google AI Studio](http://aistudio.google.com/app/apikey), which also + includes a separate free tier. + +- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or + `unable to get local issuer certificate`** + - **Cause:** You may be on a corporate network with a firewall that intercepts + and inspects SSL/TLS traffic. This often requires a custom root CA + certificate to be trusted by Node.js. + - **Solution:** First try setting `NODE_USE_SYSTEM_CA`; if that does not + resolve the issue, set `NODE_EXTRA_CA_CERTS`. + - Set the `NODE_USE_SYSTEM_CA=1` environment variable to tell Node.js to use + the operating system's native certificate store (where corporate + certificates are typically already installed). + - Example: `export NODE_USE_SYSTEM_CA=1` (Windows PowerShell: + `$env:NODE_USE_SYSTEM_CA=1`) + - Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of + your corporate root CA certificate file. + - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` + (Windows PowerShell: + `$env:NODE_EXTRA_CA_CERTS="C:\path\to\your\corporate-ca.crt"`) + +## Common error messages and solutions + +- **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.** + + - **Cause:** Another process is already using the port that the MCP server is + trying to bind to. + - **Solution:** Either stop the other process that is using the port or + configure the MCP server to use a different port. + +- **Error: Command not found (when attempting to run Gemini CLI with + `gemini`).** + + - **Cause:** Gemini CLI is not correctly installed or it is not in your + system's `PATH`. + - **Solution:** The update depends on how you installed Gemini CLI: + - If you installed `gemini` globally, check that your `npm` global binary + directory is in your `PATH`. You can update Gemini CLI using the command + `npm install -g @google/gemini-cli@latest`. + - If you are running `gemini` from source, ensure you are using the correct + command to invoke it (for example, `node packages/cli/dist/index.js ...`). + To update Gemini CLI, pull the latest changes from the repository, and + then rebuild using the command `npm run build`. + +- **Error: `MODULE_NOT_FOUND` or import errors.** + + - **Cause:** Dependencies are not installed correctly, or the project hasn't + been built. + - **Solution:** + 1. Run `npm install` to ensure all dependencies are present. + 2. Run `npm run build` to compile the project. + 3. Verify that the build completed successfully with `npm run start`. + +- **Error: "Operation not permitted", "Permission denied", or similar.** + + - **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations + that are restricted by your sandbox configuration, such as writing outside + the project directory or system temp directory. + - **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md) + documentation for more information, including how to customize your sandbox + configuration. + +- **Gemini CLI is not running in interactive mode in "CI" environments** + + - **Issue:** Gemini CLI does not enter interactive mode (no prompt appears) if + an environment variable starting with `CI_` (for example, `CI_TOKEN`) is + set. This is because the `is-in-ci` package, used by the underlying UI + framework, detects these variables and assumes a non-interactive CI + environment. + - **Cause:** The `is-in-ci` package checks for the presence of `CI`, + `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. + When any of these are found, it signals that the environment is + non-interactive, which prevents Gemini CLI from starting in its interactive + mode. + - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to + function, you can temporarily unset it for the command. For example, + `env -u CI_TOKEN gemini` + +- **DEBUG mode not working from project .env file** + + - **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable + debug mode for gemini-cli. + - **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded + from project `.env` files to prevent interference with gemini-cli behavior. + - **Solution:** Use a `.gemini/.env` file instead, or configure the + `advanced.excludedEnvVars` setting in your `settings.json` to exclude fewer + variables. + +- **Warning: `npm WARN deprecated node-domexception@1.0.0` or + `npm WARN deprecated glob` during install/update** + - **Issue:** When installing or updating Gemini CLI globally via + `npm install -g @google/gemini-cli` or `npm update -g @google/gemini-cli`, + you might see deprecation warnings regarding `node-domexception` or old + versions of `glob`. + - **Cause:** These warnings occur because some dependencies (or their + sub-dependencies, like `google-auth-library`) rely on older package + versions. Since Gemini CLI requires Node.js 20 or higher, the platform's + native features (like the native `DOMException`) are used, making these + warnings purely informational. + - **Solution:** These warnings are harmless and can be safely ignored. Your + installation or update will complete successfully and function properly + without any action required. + +## Exit codes + +Gemini CLI uses specific exit codes to indicate the reason for termination. This +is especially useful for scripting and automation. + +| Exit Code | Error Type | Description | +| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- | +| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. | +| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) | +| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (for example, Docker, Podman, or Seatbelt). | +| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. | +| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) | + +## Debugging tips + +- **CLI debugging:** + + - Use the `--debug` flag for more detailed output. In interactive mode, press + F12 to view the debug console. + - Check the CLI logs, often found in a user-specific configuration or cache + directory. + +- **Core debugging:** + + - Check the server console output for error messages or stack traces. + - Increase log verbosity if configurable. For example, set the `DEBUG_MODE` + environment variable to `true` or `1`. + - Use Node.js debugging tools (for example, `node --inspect`) if you need to + step through server-side code. + +- **Tool issues:** + + - If a specific tool is failing, try to isolate the issue by running the + simplest possible version of the command or operation the tool performs. + - For `run_shell_command`, check that the command works directly in your shell + first. + - For _file system tools_, verify that paths are correct and check the + permissions. + +- **Pre-flight checks:** + - Always run `npm run preflight` before committing code. This can catch many + common issues related to formatting, linting, and type errors. + +## Existing GitHub issues similar to yours or creating new issues + +If you encounter an issue that was not covered here in this _Troubleshooting +guide_, consider searching Gemini CLI +[Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). +If you can't find an issue similar to yours, consider creating a new GitHub +Issue with a detailed description. Pull requests are also welcome! + + +> [!NOTE] +> Issues tagged as "πŸ”’Maintainers only" are reserved for project +> maintainers. We will not accept pull requests related to these issues. diff --git a/docs/resources/uninstall.md b/docs/resources/uninstall.md new file mode 100644 index 0000000000000000000000000000000000000000..60d8eac9b7d7c337545d6db6ac030a148440b301 --- /dev/null +++ b/docs/resources/uninstall.md @@ -0,0 +1,57 @@ +# Uninstalling the CLI + +Your uninstall method depends on how you ran the CLI. Follow the instructions +for either npx or a global npm installation. + +## Method 1: Using npx + +npx runs packages from a temporary cache without a permanent installation. To +"uninstall" the CLI, you must clear this cache, which will remove gemini-cli and +any other packages previously executed with npx. + +The npx cache is a directory named `_npx` inside your main npm cache folder. You +can find your npm cache path by running `npm config get cache`. + +**For macOS / Linux** + +```bash +# The path is typically ~/.npm/_npx +rm -rf "$(npm config get cache)/_npx" +``` + +**For Windows (PowerShell)** + +```powershell +# The path is typically $env:LocalAppData\npm-cache\_npx +Remove-Item -Path (Join-Path $env:LocalAppData "npm-cache\_npx") -Recurse -Force +``` + +## Method 2: Using npm (global install) + +If you installed the CLI globally (for example, +`npm install -g @google/gemini-cli`), use the `npm uninstall` command with the +`-g` flag to remove it. + +```bash +npm uninstall -g @google/gemini-cli +``` + +This command completely removes the package from your system. + +## Method 3: Homebrew + +If you installed the CLI globally using Homebrew (for example, +`brew install gemini-cli`), use the `brew uninstall` command to remove it. + +```bash +brew uninstall gemini-cli +``` + +## Method 4: MacPorts + +If you installed the CLI globally using MacPorts (for example, +`sudo port install gemini-cli`), use the `port uninstall` command to remove it. + +```bash +sudo port uninstall gemini-cli +``` diff --git a/docs/tools/activate-skill.md b/docs/tools/activate-skill.md new file mode 100644 index 0000000000000000000000000000000000000000..7b8ca2003a7c40d0d4980161d626f4fa34d69ed7 --- /dev/null +++ b/docs/tools/activate-skill.md @@ -0,0 +1,43 @@ +# Activate skill tool (`activate_skill`) + +The `activate_skill` tool lets Gemini CLI load specialized procedural expertise +and resources when they are relevant to your request. + +## Description + +Skills are packages of instructions and tools designed for specific engineering +tasks, such as reviewing code or creating pull requests. Gemini CLI uses this +tool to "activate" a skill, which provides it with detailed guidelines and +specialized tools tailored to that task. + +### Arguments + +`activate_skill` takes one argument: + +- `name` (enum, required): The name of the skill to activate (for example, + `code-reviewer`, `pr-creator`, or `docs-writer`). + +## Usage + +The `activate_skill` tool is used exclusively by the Gemini agent. You cannot +invoke this tool manually. + +When the agent identifies that a task matches a discovered skill, it requests to +activate that skill. Once activated, the agent's behavior is guided by the +skill's specific instructions until the task is complete. + +## Behavior + +The agent uses this tool to provide professional-grade assistance: + +- **Specialized logic:** Skills contain expert-level procedures for complex + workflows. +- **Dynamic capability:** Activating a skill can grant the agent access to new, + task-specific tools. +- **Contextual awareness:** Skills help the agent focus on the most relevant + standards and conventions for a particular task. + +## Next steps + +- Learn how to [Use Agent Skills](../cli/skills.md). +- See the [Build agent skills](../cli/creating-skills.md) guide. diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md new file mode 100644 index 0000000000000000000000000000000000000000..55a95f52f85967745e9b1daecdb9d79f47629d00 --- /dev/null +++ b/docs/tools/ask-user.md @@ -0,0 +1,98 @@ +# Ask User Tool + +The `ask_user` tool lets Gemini CLI ask you one or more questions to gather +preferences, clarify requirements, or make decisions. It supports multiple +question types including multiple-choice, free-form text, and Yes/No +confirmation. + +## `ask_user` (Ask User) + +- **Tool name:** `ask_user` +- **Display name:** Ask User +- **File:** `ask-user.ts` +- **Parameters:** + + - `questions` (array of objects, required): A list of 1 to 4 questions to ask. + Each question object has the following properties: + - `question` (string, required): The complete question text. + - `header` (string, required): A short label (max 16 chars) displayed as a + chip/tag (for example, "Auth", "Database"). + - `type` (string, optional): The type of question. Defaults to `'choice'`. + - `'choice'`: Multiple-choice with options (supports multi-select). + - `'text'`: Free-form text input. + - `'yesno'`: Yes/No confirmation. + - `options` (array of objects, optional): Required for `'choice'` type. 2-4 + selectable options. + - `label` (string, required): Display text (1-5 words). + - `description` (string, required): Brief explanation. + - `multiSelect` (boolean, optional): For `'choice'` type, allows selecting + multiple options. Automatically adds an "All the above" option if there + are multiple standard options. + - `placeholder` (string, optional): Hint text for input fields. + +- **Behavior:** + + - Presents an interactive dialog to the user with the specified questions. + - Pauses execution until the user provides answers or dismisses the dialog. + - Returns the user's answers to the model. + +- **Output (`llmContent`):** A JSON string containing the user's answers, + indexed by question position (for example, + `{"answers":{"0": "Option A", "1": "Some text"}}`). + +- **Confirmation:** Yes. The tool inherently involves user interaction. + +## Usage Examples + +### Multiple Choice Question + +```json +{ + "questions": [ + { + "header": "Database", + "question": "Which database would you like to use?", + "type": "choice", + "options": [ + { + "label": "PostgreSQL", + "description": "Powerful, open source object-relational database system." + }, + { + "label": "SQLite", + "description": "C-library that implements a SQL database engine." + } + ] + } + ] +} +``` + +### Text Input Question + +```json +{ + "questions": [ + { + "header": "Project Name", + "question": "What is the name of your new project?", + "type": "text", + "placeholder": "for example, my-awesome-app" + } + ] +} +``` + +### Yes/No Question + +```json +{ + "questions": [ + { + "header": "Deploy", + "question": "Do you want to deploy the application now?", + "type": "yesno" + } + ] +} +``` diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md new file mode 100644 index 0000000000000000000000000000000000000000..83c3691dd35b86c02136157cf215b198ba6e61f8 --- /dev/null +++ b/docs/tools/file-system.md @@ -0,0 +1,129 @@ +# File system tools reference + +Gemini CLI core provides a suite of tools for interacting with the local file +system. These tools allow the model to explore and modify your codebase. + +## Technical reference + +All file system tools operate within a `rootDirectory` (the current working +directory or workspace root) for security. + +### `list_directory` (ReadFolder) + +Lists the names of files and subdirectories directly within a specified path. + +- **Tool name:** `list_directory` +- **Arguments:** + - `dir_path` (string, required): Absolute or relative path to the directory. + - `ignore` (array, optional): Glob patterns to exclude. + - `file_filtering_options` (object, optional): Configuration for `.gitignore` + and `.geminiignore` compliance. + +### `read_file` (ReadFile) + +Reads and returns the content of a specific file. Supports text, images, audio, +and PDF. + +- **Tool name:** `read_file` +- **Arguments:** + - `file_path` (string, required): Path to the file. + - `offset` (number, optional): Start line for text files (0-based). + - `limit` (number, optional): Maximum lines to read. + +### `write_file` (WriteFile) + +Writes content to a specified file, overwriting it if it exists or creating it +if not. + +- **Tool name:** `write_file` +- **Arguments:** + - `file_path` (string, required): Path to the file. + - `content` (string, required): Data to write. +- **Confirmation:** Requires manual user approval. + +### `glob` (FindFiles) + +Finds files matching specific glob patterns across the workspace. + +- **Tool name:** `glob` +- **Display name:** FindFiles +- **File:** `glob.ts` +- **Parameters:** + - `pattern` (string, required): The glob pattern to match against (for + example, `"*.py"`, `"src/**/*.js"`). + - `path` (string, optional): The absolute path to the directory to search + within. If omitted, searches the tool's root directory. + - `case_sensitive` (boolean, optional): Whether the search should be + case-sensitive. Defaults to `false`. + - `respect_git_ignore` (boolean, optional): Whether to respect .gitignore + patterns when finding files. Defaults to `true`. +- **Behavior:** + - Searches for files matching the glob pattern within the specified directory. + - Returns a list of absolute paths, sorted with the most recently modified + files first. + - Ignores common nuisance directories like `node_modules` and `.git` by + default. +- **Output (`llmContent`):** A message like: + `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...` +- **Confirmation:** No. + +### `grep_search` (SearchText) + +`grep_search` searches for a regular expression pattern within the content of +files in a specified directory. Can filter files by a glob pattern. Returns the +lines containing matches, along with their file paths and line numbers. + +- **Tool name:** `grep_search` +- **Display name:** SearchText +- **File:** `grep.ts` +- **Parameters:** + - `pattern` (string, required): The regular expression (regex) to search for + (for example, `"function\s+myFunction"`). + - `path` (string, optional): The absolute path to the directory to search + within. Defaults to the current working directory. + - `include` (string, optional): A glob pattern to filter which files are + searched (for example, `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, + searches most files (respecting common ignores). +- **Behavior:** + - Uses `git grep` if available in a Git repository for speed; otherwise, falls + back to system `grep` or a JavaScript-based search. + - Returns a list of matching lines, each prefixed with its file path (relative + to the search directory) and line number. +- **Output (`llmContent`):** A formatted string of matches, for example: + ``` + Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"): + --- + File: src/utils.ts + L15: export function myFunction() { + L22: myFunction.call(); + --- + File: src/index.ts + L5: import { myFunction } from './utils'; + --- + ``` +- **Confirmation:** No. + +### `replace` (Edit) + +`replace` replaces text within a file. By default, the tool expects to find and +replace exactly ONE occurrence of `old_string`. If you want to replace multiple +occurrences of the exact same string, set `allow_multiple` to `true`. This tool +is designed for precise, targeted changes and requires significant context +around the `old_string` to ensure it modifies the correct location. + +- **Tool name:** `replace` +- **Arguments:** + - `file_path` (string, required): Path to the file. + - `instruction` (string, required): Semantic description of the change. + - `old_string` (string, required): Exact literal text to find. + - `new_string` (string, required): Exact literal text to replace with. + - `allow_multiple` (boolean, optional): If `true`, replaces all occurrences. + If `false` (default), only succeeds if exactly one occurrence is found. +- **Confirmation:** Requires manual user approval. + +## Next steps + +- Follow the [File management tutorial](../cli/tutorials/file-management.md) for + practical examples. +- Learn about [Trusted folders](../cli/trusted-folders.md) to manage access + permissions. diff --git a/docs/tools/internal-docs.md b/docs/tools/internal-docs.md new file mode 100644 index 0000000000000000000000000000000000000000..0d30655fcda2c352cbf8bfca8c3317d1a20551aa --- /dev/null +++ b/docs/tools/internal-docs.md @@ -0,0 +1,46 @@ +# Internal documentation tool (`get_internal_docs`) + +The `get_internal_docs` tool lets Gemini CLI access its own technical +documentation to provide more accurate answers about its capabilities and usage. + +## Description + +This tool is used when Gemini CLI needs to verify specific details about Gemini +CLI's internal features, built-in commands, or configuration options. It +provides direct access to the Markdown files in the `docs/` directory. + +### Arguments + +`get_internal_docs` takes one optional argument: + +- `path` (string, optional): The relative path to a specific documentation file + (for example, `reference/commands.md`). If omitted, the tool returns a list of + all available documentation paths. + +## Usage + +The `get_internal_docs` tool is used exclusively by Gemini CLI. You cannot +invoke this tool manually. + +When Gemini CLI uses this tool, it retrieves the content of the requested +documentation file and processes it to answer your question. This ensures that +the information provided by the AI is grounded in the latest project +documentation. + +## Behavior + +Gemini CLI uses this tool to ensure technical accuracy: + +- **Capability discovery:** If Gemini CLI is unsure how a feature works, it can + lookup the corresponding documentation. +- **Reference lookup:** Gemini CLI can verify slash command sub-commands or + specific setting names. +- **Self-correction:** Gemini CLI can use the documentation to correct its + understanding of Gemini CLI's system logic. + +## Next steps + +- Explore the [Command reference](../reference/commands.md) for a detailed guide + to slash commands. +- See the [Configuration guide](../reference/configuration.md) for settings + reference. diff --git a/docs/tools/mcp-resources.md b/docs/tools/mcp-resources.md new file mode 100644 index 0000000000000000000000000000000000000000..a6dad26e10fa938355253e13ff987ad3a7250d5a --- /dev/null +++ b/docs/tools/mcp-resources.md @@ -0,0 +1,44 @@ +# MCP resource tools + +MCP resource tools let Gemini CLI discover and retrieve data from contextual +resources exposed by Model Context Protocol (MCP) servers. + +## 1. `list_mcp_resources` (ListMcpResources) + +`list_mcp_resources` retrieves a list of all available resources from connected +MCP servers. This is primarily a discovery tool that helps the model understand +what external data sources are available for reference. + +- **Tool name:** `list_mcp_resources` +- **Display name:** List MCP Resources +- **Kind:** `Search` +- **File:** `list-mcp-resources.ts` +- **Parameters:** + - `serverName` (string, optional): An optional filter to list resources from a + specific server. +- **Behavior:** + - Iterates through all connected MCP servers. + - Fetches the list of resources each server exposes. + - Formats the results into a plain-text list of URIs and descriptions. +- **Output (`llmContent`):** A formatted list of available resources, including + their URI, server name, and optional description. +- **Confirmation:** No. This is a read-only discovery tool. + +## 2. `read_mcp_resource` (ReadMcpResource) + +`read_mcp_resource` retrieves the content of a specific resource identified by +its URI. + +- **Tool name:** `read_mcp_resource` +- **Display name:** Read MCP Resource +- **Kind:** `Read` +- **File:** `read-mcp-resource.ts` +- **Parameters:** + - `uri` (string, required): The URI of the MCP resource to read. +- **Behavior:** + - Locates the resource and its associated server by URI. + - Calls the server's `resources/read` method. + - Processes the response, extracting text or binary data. +- **Output (`llmContent`):** The content of the resource. For binary data, it + returns a placeholder indicating the data type. +- **Confirmation:** No. This is a read-only retrieval tool. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md new file mode 100644 index 0000000000000000000000000000000000000000..bcebafc528dca771deafe00a0e8e49ed9645eadc --- /dev/null +++ b/docs/tools/mcp-server.md @@ -0,0 +1,1286 @@ +# MCP servers with Gemini CLI + +This document provides a guide to configuring and using Model Context Protocol +(MCP) servers with Gemini CLI. + +## What is an MCP server? + +An MCP server is an application that exposes tools and resources to the Gemini +CLI through the Model Context Protocol, allowing it to interact with external +systems and data sources. MCP servers act as a bridge between the Gemini model +and your local environment or other services like APIs. + +An MCP server enables Gemini CLI to: + +- **Discover tools:** List available tools, their descriptions, and parameters + through standardized schema definitions. +- **Execute tools:** Call specific tools with defined arguments and receive + structured responses. +- **Access resources:** Read data from specific resources that the server + exposes (files, API payloads, reports, etc.). + +With an MCP server, you can extend Gemini CLI's capabilities to perform actions +beyond its built-in features, such as interacting with databases, APIs, custom +scripts, or specialized workflows. + +## Core integration architecture + +Gemini CLI integrates with MCP servers through a sophisticated discovery and +execution system built into the core package (`packages/core/src/tools/`): + +### Discovery Layer (`mcp-client.ts`) + +The discovery process is orchestrated by `discoverMcpTools()`, which: + +1. **Iterates through configured servers** from your `settings.json` + `mcpServers` configuration +2. **Establishes connections** using appropriate transport mechanisms (Stdio, + SSE, or Streamable HTTP) +3. **Fetches tool definitions** from each server using the MCP protocol +4. **Sanitizes and validates** tool schemas for compatibility with the Gemini + API +5. **Registers tools** in the global tool registry with conflict resolution +6. **Fetches and registers resources** if the server exposes any + +### Execution layer (`mcp-tool.ts`) + +Each discovered MCP tool is wrapped in a `DiscoveredMCPTool` instance that: + +- **Handles confirmation logic** based on server trust settings and user + preferences +- **Manages tool execution** by calling the MCP server with proper parameters +- **Processes responses** for both the LLM context and user display +- **Maintains connection state** and handles timeouts + +### Transport mechanisms + +Gemini CLI supports three MCP transport types: + +- **Stdio Transport:** Spawns a subprocess and communicates via stdin/stdout +- **SSE Transport:** Connects to Server-Sent Events endpoints +- **Streamable HTTP Transport:** Uses HTTP streaming for communication + +## Working with MCP resources + +Some MCP servers expose contextual β€œresources” in addition to the tools and +prompts. Gemini CLI discovers these automatically and gives you the possibility +to reference them in the chat. For more information on the tools used to +interact with these resources, see [MCP resource tools](mcp-resources.md). + +### Discovery and listing + +- When discovery runs, the CLI fetches each server’s `resources/list` results. +- The `/mcp` command displays a Resources section alongside Tools and Prompts + for every connected server. + +This returns a concise, plain-text list of URIs plus metadata. + +### Referencing resources in a conversation + +You can use the same `@` syntax already known for referencing local files: + +``` +@server://resource/path +``` + +Resource URIs appear in the completion menu together with filesystem paths. When +you submit the message, the CLI calls `resources/read` and injects the content +in the conversation. + +## How to set up your MCP server + +Gemini CLI uses the `mcpServers` configuration in your `settings.json` file to +locate and connect to MCP servers. This configuration supports multiple servers +with different transport mechanisms. + +### Configure the MCP server in settings.json + +You can configure MCP servers in your `settings.json` file in two main ways: +through the top-level `mcpServers` object for specific server definitions, and +through the `mcp` object for global settings that control server discovery and +execution. + +#### Global MCP settings (`mcp`) + +The `mcp` object in your `settings.json` lets you define global rules for all +MCP servers. + +- **`mcp.serverCommand`** (string): A global command to start an MCP server. +- **`mcp.allowed`** (array of strings): A list of MCP server names to allow. If + this is set, only servers from this list (matching the keys in the + `mcpServers` object) will be connected to. +- **`mcp.excluded`** (array of strings): A list of MCP server names to exclude. + Servers in this list will not be connected to. + +**Example:** + +```json +{ + "mcp": { + "allowed": ["my-trusted-server"], + "excluded": ["experimental-server"] + } +} +``` + +#### Server-specific configuration (`mcpServers`) + +The `mcpServers` object is where you define each individual MCP server you want +the CLI to connect to. + +### Configuration structure + +Add an `mcpServers` object to your `settings.json` file: + +```json +{ ...file contains other config objects + "mcpServers": { + "serverName": { + "command": "path/to/server", + "args": ["--arg1", "value1"], + "env": { + "API_KEY": "$MY_API_TOKEN" + }, + "cwd": "./server-directory", + "timeout": 30000, + "trust": false + } + } +} +``` + +### Configuration properties + +Each server configuration supports the following properties: + +#### Required (one of the following) + +- **`command`** (string): Path to the executable for Stdio transport +- **`url`** (string): SSE endpoint URL (for example, + `"http://localhost:8080/sse"`) +- **`httpUrl`** (string): HTTP streaming endpoint URL + +#### Optional + +- **`args`** (string[]): Command-line arguments for Stdio transport +- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl` +- **`env`** (object): Environment variables for the server process. Values can + reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all + platforms), or `%VAR_NAME%` (Windows only). +- **`cwd`** (string): Working directory for Stdio transport +- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = + 10 minutes) +- **`trust`** (boolean): When `true`, bypasses all tool call confirmations for + this server (default: `false`) +- **`includeTools`** (string[]): List of tool names to include from this MCP + server. When specified, only the tools listed here will be available from this + server (allowlist behavior). If not specified, all tools from the server are + enabled by default. +- **`excludeTools`** (string[]): List of tool names to exclude from this MCP + server. Tools listed here will not be available to the model, even if they are + exposed by the server. `excludeTools` takes precedence over `includeTools`. If + a tool is in both lists, it will be excluded. +- **`targetAudience`** (string): The OAuth Client ID allowlisted on the + IAP-protected application you are trying to access. Used with + `authProviderType: 'service_account_impersonation'`. +- **`targetServiceAccount`** (string): The email address of the Google Cloud + Service Account to impersonate. Used with + `authProviderType: 'service_account_impersonation'`. + +### Environment variable expansion + +Gemini CLI automatically expands environment variables in the `env` block of +your MCP server configuration. This lets you securely reference variables +defined in your shell or environment without hardcoding sensitive information +directly in your `settings.json` file. + +The expansion utility supports: + +- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on + all platforms) +- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows) + +If a variable is not defined in the current environment, it resolves to an empty +string. + +**Example:** + +```json +"env": { + "API_KEY": "$MY_EXTERNAL_TOKEN", + "LOG_LEVEL": "$LOG_LEVEL", + "TEMP_DIR": "%TEMP%" +} +``` + +### Security and environment sanitization + +To protect your credentials, Gemini CLI performs environment sanitization when +spawning MCP server processes. + +#### Automatic redaction + +By default, the CLI redacts sensitive environment variables from the base +environment (inherited from the host process). This prevents the accidental +leakage of sensitive host environment variables (like AWS keys or GitHub tokens) +to arbitrary third-party MCP servers that might execute malicious code or log +your environment. This includes: + +- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc. +- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, + `*KEY*`, `*AUTH*`, `*CREDENTIAL*`. +- Certificates and private key patterns. + +#### Explicit overrides + +If an environment variable must be passed to an MCP server, you must explicitly +state it in the `env` property of the server configuration in `settings.json` +(or `mcp_config.json` if configuring standard MCP clients or remote skills). +Explicitly defined variables (including those from extensions) are trusted and +are **not** subjected to the automatic redaction process. + +This follows the security principle that if a variable is explicitly configured +by the user for a specific server, it constitutes informed consent to share that +specific data with that server. + + +> [!NOTE] +> Even when explicitly defined, you should avoid hardcoding secrets. +> Instead, use environment variable expansion +> (for example, `"MY_KEY": "$MY_KEY"`) to securely pull the value from your host +> environment at runtime. + +**Example: Passing a GitHub Token securely to the +[official GitHub MCP server](https://github.com/github/github-mcp-server) via +`mcp_config.json`** + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@github/github-mcp-server"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PERSONAL_ACCESS_TOKEN" + } + } + } +} +``` + +### OAuth support for remote MCP servers + +Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or +HTTP transports. This enables secure access to MCP servers that require +authentication. + +#### Automatic OAuth discovery + +For servers that support OAuth discovery, you can omit the OAuth configuration +and let the CLI discover it automatically: + +```json +{ + "mcpServers": { + "discoveredServer": { + "url": "https://api.example.com/sse" + } + } +} +``` + +The CLI will automatically: + +- Detect when a server requires OAuth authentication (401 responses) +- Discover OAuth endpoints from server metadata +- Perform dynamic client registration if supported +- Handle the OAuth flow and token management + +#### Authentication flow + +When connecting to an OAuth-enabled server: + +1. **Initial connection attempt** fails with 401 Unauthorized +2. **OAuth discovery** finds authorization and token endpoints +3. **Browser opens** for user authentication (requires local browser access) +4. **Authorization code** is exchanged for access tokens +5. **Tokens are stored** securely for future use +6. **Connection retry** succeeds with valid tokens + +#### Browser redirect requirements + + +> [!IMPORTANT] +> OAuth authentication requires that your local machine can: +> +> - Open a web browser for authentication +> - Receive redirects on `http://localhost:/oauth/callback` (or a specific port if configured via `redirectUri`) + +This feature will not work in: + +- Headless environments without browser access +- Remote SSH sessions without X11 forwarding +- Containerized environments without browser support + +#### Authorization server requirements (RFC 9207) + + +> [!IMPORTANT] +> To protect against OAuth Identity Provider (IdP) mix-up attacks, Gemini CLI +> validates the `iss` (issuer) parameter per +> [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). When an expected issuer is +> discovered or configured, authorization servers **must** return the `iss` +> parameter in the callback redirect matching the issuer URL. Responses missing +> `iss` or with mismatched issuers are rejected with HTTP 400. + +##### Expected authorization callback example + +When the authorization server redirects the user back to Gemini CLI, the +redirect URI must include the `iss` parameter: + +```http +HTTP/1.1 302 Found +Location: http://localhost:/oauth/callback?code=AUTH_CODE&state=STATE&iss=https%3A%2F%2Fauth.example.com +``` + +- **Valid response (accepted):** `iss` matches the configured or discovered + issuer (`https://auth.example.com`). +- **Missing `iss` (rejected):** + `http://localhost:/oauth/callback?code=AUTH_CODE&state=STATE` (fails + with HTTP 400: `Missing issuer parameter in response`). +- **Mismatched `iss` (rejected):** `iss` points to a different domain or + includes userinfo (fails with HTTP 400: `Issuer mismatch`). + +##### Configuration example with explicit issuer + +If your remote MCP server uses an authorization server with a known issuer URL: + +```json +{ + "mcpServers": { + "secureRemoteServer": { + "url": "https://mcp.example.com/sse", + "oauth": { + "enabled": true, + "issuer": "https://auth.example.com", + "authorizationUrl": "https://auth.example.com/oauth/authorize", + "tokenUrl": "https://auth.example.com/oauth/token", + "clientId": "gemini-cli-client", + "scopes": ["mcp:read", "mcp:write"] + } + } + } +} +``` + +#### Managing OAuth authentication + +Use the `/mcp auth` command to manage OAuth authentication: + +```bash +# List servers requiring authentication +/mcp auth + +# Authenticate with a specific server +/mcp auth serverName + +# Re-authenticate if tokens expire +/mcp auth serverName +``` + +#### OAuth configuration properties + +- **`enabled`** (boolean): Enable OAuth for this server +- **`clientId`** (string): OAuth client identifier (optional with dynamic + registration) +- **`clientSecret`** (string): OAuth client secret (optional for public clients) +- **`authorizationUrl`** (string): OAuth authorization endpoint (auto-discovered + if omitted) +- **`issuer`** (string): Authorization server issuer URL (auto-discovered if + omitted; validated per RFC 9207) +- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted) +- **`scopes`** (string[]): Required OAuth scopes +- **`redirectUri`** (string): Custom redirect URI (defaults to an OS-assigned + random port, e.g., `http://localhost:/oauth/callback`) +- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs +- **`audiences`** (string[]): Audiences the token is valid for + +#### Token management + +OAuth tokens are automatically: + +- **Stored securely** in `~/.gemini/mcp-oauth-tokens.json` +- **Refreshed** when expired (if refresh tokens are available) +- **Validated** before each connection attempt +- **Cleaned up** when invalid or expired + +#### Authentication provider type + +You can specify the authentication provider type using the `authProviderType` +property: + +- **`authProviderType`** (string): Specifies the authentication provider. Can be + one of the following: + - **`dynamic_discovery`** (default): The CLI will automatically discover the + OAuth configuration from the server. + - **`google_credentials`**: The CLI will use the Google Application Default + Credentials (ADC) to authenticate with the server. When using this provider, + you must specify the required scopes. + - **`service_account_impersonation`**: The CLI will impersonate a Google Cloud + Service Account to authenticate with the server. This is useful for + accessing IAP-protected services (this was specifically designed for Cloud + Run services). + +#### Google credentials + +```json +{ + "mcpServers": { + "googleCloudServer": { + "httpUrl": "https://my-gcp-service.run.app/mcp", + "authProviderType": "google_credentials", + "oauth": { + "scopes": ["https://www.googleapis.com/auth/userinfo.email"] + } + } + } +} +``` + +#### Service account impersonation + +To authenticate with a server using Service Account Impersonation, you must set +the `authProviderType` to `service_account_impersonation` and provide the +following properties: + +- **`targetAudience`** (string): The OAuth Client ID allowlisted on the + IAP-protected application you are trying to access. +- **`targetServiceAccount`** (string): The email address of the Google Cloud + Service Account to impersonate. + +The CLI will use your local Application Default Credentials (ADC) to generate an +OIDC ID token for the specified service account and audience. This token will +then be used to authenticate with the MCP server. + +#### Setup instructions + +1. **[Create](https://cloud.google.com/iap/docs/oauth-client-creation) or use an + existing OAuth 2.0 client ID.** To use an existing OAuth 2.0 client ID, + follow the steps in + [How to share OAuth Clients](https://cloud.google.com/iap/docs/sharing-oauth-clients). +2. **Add the OAuth ID to the allowlist for + [programmatic access](https://cloud.google.com/iap/docs/sharing-oauth-clients#programmatic_access) + for the application.** Since Cloud Run is not yet a supported resource type + in gcloud iap, you must allowlist the Client ID on the project. +3. **Create a service account.** + [Documentation](https://cloud.google.com/iam/docs/service-accounts-create#creating), + [Cloud Console Link](https://console.cloud.google.com/iam-admin/serviceaccounts) +4. **Add both the service account and users to the IAP Policy** in the + "Security" tab of the Cloud Run service itself or via gcloud. +5. **Grant all users and groups** who will access the MCP Server the necessary + permissions to + [impersonate the service account](https://cloud.google.com/docs/authentication/use-service-account-impersonation) + (for example, `roles/iam.serviceAccountTokenCreator`). +6. **[Enable](https://console.cloud.google.com/apis/library/iamcredentials.googleapis.com) + the IAM Credentials API** for your project. + +### Example configurations + +#### Python MCP server (stdio) + +```json +{ + "mcpServers": { + "pythonTools": { + "command": "python", + "args": ["-m", "my_mcp_server", "--port", "8080"], + "cwd": "./mcp-servers/python", + "env": { + "DATABASE_URL": "$DB_CONNECTION_STRING", + "API_KEY": "${EXTERNAL_API_KEY}" + }, + "timeout": 15000 + } + } +} +``` + +#### Node.js MCP server (stdio) + +```json +{ + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["dist/server.js", "--verbose"], + "cwd": "./mcp-servers/node", + "trust": true + } + } +} +``` + +#### Docker-based MCP server + +```json +{ + "mcpServers": { + "dockerizedServer": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "API_KEY", + "-v", + "${PWD}:/workspace", + "my-mcp-server:latest" + ], + "env": { + "API_KEY": "$EXTERNAL_SERVICE_TOKEN" + } + } + } +} +``` + +#### HTTP-based MCP server + +```json +{ + "mcpServers": { + "httpServer": { + "httpUrl": "http://localhost:3000/mcp", + "timeout": 5000 + } + } +} +``` + +#### HTTP-based MCP Server with custom headers + +```json +{ + "mcpServers": { + "httpServerWithAuth": { + "httpUrl": "http://localhost:3000/mcp", + "headers": { + "Authorization": "Bearer your-api-token", + "X-Custom-Header": "custom-value", + "Content-Type": "application/json" + }, + "timeout": 5000 + } + } +} +``` + +#### MCP server with tool filtering + +```json +{ + "mcpServers": { + "filteredServer": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "includeTools": ["safe_tool", "file_reader", "data_processor"], + // "excludeTools": ["dangerous_tool", "file_deleter"], + "timeout": 30000 + } + } +} +``` + +### SSE MCP server with SA impersonation + +```json +{ + "mcpServers": { + "myIapProtectedServer": { + "url": "https://my-iap-service.run.app/sse", + "authProviderType": "service_account_impersonation", + "targetAudience": "YOUR_IAP_CLIENT_ID.apps.googleusercontent.com", + "targetServiceAccount": "your-sa@your-project.iam.gserviceaccount.com" + } + } +} +``` + +## Discovery process deep dive + +When Gemini CLI starts, it performs MCP server discovery through the following +detailed process: + +### 1. Server iteration and connection + +For each configured server in `mcpServers`: + +1. **Status tracking begins:** Server status is set to `CONNECTING` +2. **Transport selection:** Based on configuration properties: + - `httpUrl` β†’ `StreamableHTTPClientTransport` + - `url` β†’ `SSEClientTransport` + - `command` β†’ `StdioClientTransport` +3. **Connection establishment:** The MCP client attempts to connect with the + configured timeout +4. **Error handling:** Connection failures are logged and the server status is + set to `DISCONNECTED` + +### 2. Tool discovery + +Upon successful connection: + +1. **Tool listing:** The client calls the MCP server's tool listing endpoint +2. **Schema validation:** Each tool's function declaration is validated +3. **Tool filtering:** Tools are filtered based on `includeTools` and + `excludeTools` configuration +4. **Name sanitization:** Tool names are cleaned to meet Gemini API + requirements: + - Characters other than letters, numbers, underscore (`_`), hyphen (`-`), dot + (`.`), and colon (`:`) are replaced with underscores + - Names longer than 63 characters are truncated with middle replacement + (`...`) + +### 3. Tool naming and namespaces + +To prevent collisions across multiple servers or conflicting built-in tools, +every discovered MCP tool is assigned a strict namespace. + +1. **Automatic FQN:** All MCP tools are unconditionally assigned a fully + qualified name (FQN) using the format `mcp_{serverName}_{toolName}`. +2. **Registry tracking:** The tool registry maintains metadata mappings between + these FQNs and their original server identities. +3. **Overwrites:** If two servers share the exact same alias in your + configuration and provide tools with the exact same name, the last registered + tool overwrites the previous one. +4. **Policies:** To configure permissions (like auto-approval or denial) for MCP + tools, see + [Special syntax for MCP tools](../reference/policy-engine.md#special-syntax-for-mcp-tools) + in the Policy Engine documentation. + + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (for example, use +> `my-server` rather than `my_server`). The policy parser splits Fully Qualified +> Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` +> prefix. If your server name contains an underscore, the parser will +> misinterpret the server identity, which can cause wildcard rules and security +> policies to fail silently. + +### 4. Schema processing + +Tool parameter schemas undergo sanitization for Gemini API compatibility: + +- **`$schema` properties** are removed +- **`additionalProperties`** are stripped +- **`anyOf` with `default`** have their default values removed (Vertex AI + compatibility) +- **Recursive processing** applies to nested schemas + +### 5. Connection management + +After discovery: + +- **Persistent connections:** Servers that successfully register tools maintain + their connections +- **Cleanup:** Servers that provide no usable tools have their connections + closed +- **Status updates:** Final server statuses are set to `CONNECTED` or + `DISCONNECTED` + +## Tool execution flow + +When the Gemini model decides to use an MCP tool, the following execution flow +occurs: + +### 1. Tool invocation + +The model generates a `FunctionCall` with: + +- **Tool name:** The registered name (potentially prefixed) +- **Arguments:** JSON object matching the tool's parameter schema + +### 2. Confirmation process + +Each `DiscoveredMCPTool` implements sophisticated confirmation logic: + +#### Trust-based bypass + +```typescript +if (this.trust) { + return false; // No confirmation needed +} +``` + +#### Dynamic allow-listing + +The system maintains internal allow-lists for: + +- **Server-level:** `serverName` β†’ All tools from this server are trusted +- **Tool-level:** `serverName.toolName` β†’ This specific tool is trusted + +#### User choice handling + +When confirmation is required, users can choose: + +- **Proceed once:** Execute this time only +- **Always allow this tool:** Add to tool-level allow-list +- **Always allow this server:** Add to server-level allow-list +- **Cancel:** Abort execution + +### 3. Execution + +Upon confirmation (or trust bypass): + +1. **Parameter preparation:** Arguments are validated against the tool's schema +2. **MCP call:** The underlying `CallableTool` invokes the server with: + + ```typescript + const functionCalls = [ + { + name: this.serverToolName, // Original server tool name + args: params, + }, + ]; + ``` + +3. **Response processing:** Results are formatted for both LLM context and user + display + +### 4. Response handling + +The execution result contains: + +- **`llmContent`:** Raw response parts for the language model's context +- **`returnDisplay`:** Formatted output for user display (often JSON in markdown + code blocks) + +## How to interact with your MCP server + +### Using the `/mcp` command + +The `/mcp` command provides comprehensive information about your MCP server +setup: + +```bash +/mcp +``` + +This displays: + +- **Server list:** All configured MCP servers +- **Connection status:** `CONNECTED`, `CONNECTING`, or `DISCONNECTED` +- **Server details:** Configuration summary (excluding sensitive data) +- **Available tools:** List of tools from each server with descriptions +- **Discovery state:** Overall discovery process status + +### Example `/mcp` output + +``` +MCP Servers Status: + +πŸ“‘ pythonTools (CONNECTED) + Command: python -m my_mcp_server --port 8080 + Working Directory: ./mcp-servers/python + Timeout: 15000ms + Tools: calculate_sum, file_analyzer, data_processor + +πŸ”Œ nodeServer (DISCONNECTED) + Command: node dist/server.js --verbose + Error: Connection refused + +🐳 dockerizedServer (CONNECTED) + Command: docker run -i --rm -e API_KEY my-mcp-server:latest + Tools: mcp_dockerizedServer_docker_deploy, mcp_dockerizedServer_docker_status + +Discovery State: COMPLETED +``` + +### Tool usage + +Once discovered, MCP tools are available to the Gemini model like built-in +tools. The model will automatically: + +1. **Select appropriate tools** based on your requests +2. **Present confirmation dialogs** (unless the server is trusted) +3. **Execute tools** with proper parameters +4. **Display results** in a user-friendly format + +## Status monitoring and troubleshooting + +### Connection states + +The MCP integration tracks several states: + +#### Overriding extension configurations + +If an MCP server is provided by an extension (for example, the +`google-workspace` extension), you can still override its settings in your local +`settings.json`. Gemini CLI merges your local configuration with the extension's +defaults: + +- **Tool lists:** Tool lists are merged securely to ensure the most restrictive + policy wins: + + - **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either + source blocks a tool, it remains disabled. + - **Inclusions (`includeTools`):** Arrays are intersected. If both sources + provide an allowlist, only tools present in **both** lists are enabled. If + only one source provides an allowlist, that list is respected. + - **Precedence:** `excludeTools` always takes precedence over `includeTools`. + + This ensures you always have veto power over tools provided by an extension + and that an extension cannot re-enable tools you have omitted from your + personal allowlist. + +- **Environment variables:** The `env` objects are merged. If the same variable + is defined in both places, your local value takes precedence. +- **Scalar properties:** Properties like `command`, `url`, and `timeout` are + replaced by your local values if provided. + +**Example override:** + +```json +{ + "mcpServers": { + "google-workspace": { + "excludeTools": ["gmail.send"] + } + } +} +``` + +#### Server status (`MCPServerStatus`) + +- **`DISCONNECTED`:** Server is not connected or has errors +- **`CONNECTING`:** Connection attempt in progress +- **`CONNECTED`:** Server is connected and ready + +#### Discovery state (`MCPDiscoveryState`) + +- **`NOT_STARTED`:** Discovery hasn't begun +- **`IN_PROGRESS`:** Currently discovering servers +- **`COMPLETED`:** Discovery finished (with or without errors) + +### Common issues and solutions + +#### Server won't connect + +**Symptoms:** Server shows `DISCONNECTED` status + +**Troubleshooting:** + +1. **Check configuration:** Verify `command`, `args`, and `cwd` are correct +2. **Test manually:** Run the server command directly to ensure it works +3. **Check dependencies:** Ensure all required packages are installed +4. **Review logs:** Look for error messages in the CLI output +5. **Verify permissions:** Ensure the CLI can execute the server command + +#### No tools discovered + +**Symptoms:** Server connects but no tools are available + +**Troubleshooting:** + +1. **Verify tool registration:** Ensure your server actually registers tools +2. **Check MCP protocol:** Confirm your server implements the MCP tool listing + correctly +3. **Review server logs:** Check stderr output for server-side errors +4. **Test tool listing:** Manually test your server's tool discovery endpoint + +#### Tools not executing + +**Symptoms:** Tools are discovered but fail during execution + +**Troubleshooting:** + +1. **Parameter validation:** Ensure your tool accepts the expected parameters +2. **Schema compatibility:** Verify your input schemas are valid JSON Schema +3. **Error handling:** Check if your tool is throwing unhandled exceptions +4. **Timeout issues:** Consider increasing the `timeout` setting + +#### Sandbox compatibility + +**Symptoms:** MCP servers fail when sandboxing is enabled + +**Solutions:** + +1. **Docker-based servers:** Use Docker containers that include all dependencies +2. **Path accessibility:** Ensure server executables are available in the + sandbox +3. **Network access:** Configure sandbox to allow necessary network connections +4. **Environment variables:** Verify required environment variables are passed + through + +### Debugging tips + +1. **Enable debug mode:** Run the CLI with `--debug` for verbose output (use F12 + to open debug console in interactive mode) +2. **Check stderr:** MCP server stderr is captured and logged (INFO messages + filtered) +3. **Test isolation:** Test your MCP server independently before integrating +4. **Incremental setup:** Start with simple tools before adding complex + functionality +5. **Use `/mcp` frequently:** Monitor server status during development + +## Important notes + +### Security considerations + +- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use + cautiously and only for servers you completely control +- **Access tokens:** Be security-aware when configuring environment variables + containing API keys or tokens. See + [Security and environment sanitization](#security-and-environment-sanitization) + for details on how Gemini CLI protects your credentials. +- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are + available within the sandbox environment +- **Private data:** Using broadly scoped personal access tokens can lead to + information leakage between repositories. + +### Performance and resource management + +- **Connection persistence:** The CLI maintains persistent connections to + servers that successfully register tools +- **Automatic cleanup:** Connections to servers providing no tools are + automatically closed +- **Timeout management:** Configure appropriate timeouts based on your server's + response characteristics +- **Resource monitoring:** MCP servers run as separate processes and consume + system resources + +### Schema compatibility + +- **Property stripping:** The system automatically removes certain schema + properties (`$schema`, `additionalProperties`) for Gemini API compatibility +- **Name sanitization:** Tool names are automatically sanitized to meet API + requirements +- **Conflict resolution:** Tool name conflicts between servers are resolved + through automatic prefixing + +This comprehensive integration makes MCP servers a powerful way to extend the +Gemini CLI's capabilities while maintaining security, reliability, and ease of +use. + +## Returning rich content from tools + +MCP tools are not limited to returning simple text. You can return rich, +multi-part content, including text, images, audio, and other binary data in a +single tool response. This lets you build powerful tools that can provide +diverse information to the model in a single turn. + +All data returned from the tool is processed and sent to the model as context +for its next generation, enabling it to reason about or summarize the provided +information. + +### How it works + +To return rich content, your tool's response must adhere to the MCP +specification for a +[`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). +The `content` field of the result should be an array of `ContentBlock` objects. +Gemini CLI will correctly process this array, separating text from binary data +and packaging it for the model. + +You can mix and match different content block types in the `content` array. The +supported block types include: + +- `text` +- `image` +- `audio` +- `resource` (embedded content) +- `resource_link` + +### Example: Returning text and an image + +Here is an example of a valid JSON response from an MCP tool that returns both a +text description and an image: + +```json +{ + "content": [ + { + "type": "text", + "text": "Here is the logo you requested." + }, + { + "type": "image", + "data": "BASE64_ENCODED_IMAGE_DATA_HERE", + "mimeType": "image/png" + }, + { + "type": "text", + "text": "The logo was created in 2025." + } + ] +} +``` + +When Gemini CLI receives this response, it will: + +1. Extract all the text and combine it into a single `functionResponse` part + for the model. +2. Present the image data as a separate `inlineData` part. +3. Provide a clean, user-friendly summary in the CLI, indicating that both text + and an image were received. + +This enables you to build sophisticated tools that can provide rich, multi-modal +context to the Gemini model. + +## MCP prompts as slash commands + +In addition to tools, MCP servers can expose predefined prompts that can be +executed as slash commands within Gemini CLI. This lets you create shortcuts for +common or complex queries that can be easily invoked by name. + +### Defining prompts on the server + +Here's a small example of a stdio MCP server that defines prompts: + +```ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: 'prompt-server', + version: '1.0.0', +}); + +server.registerPrompt( + 'poem-writer', + { + title: 'Poem Writer', + description: 'Write a nice haiku', + argsSchema: { title: z.string(), mood: z.string().optional() }, + }, + ({ title, mood }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `, + }, + }, + ], + }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +This can be included in `settings.json` under `mcpServers` with: + +```json +{ + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["filename.ts"] + } + } +} +``` + +### Invoking prompts + +Once a prompt is discovered, you can invoke it using its name as a slash +command. The CLI will automatically handle parsing arguments. + +```bash +/poem-writer --title="Gemini CLI" --mood="reverent" +``` + +or, using positional arguments: + +```bash +/poem-writer "Gemini CLI" reverent +``` + +When you run this command, Gemini CLI executes the `prompts/get` method on the +MCP server with the provided arguments. The server is responsible for +substituting the arguments into the prompt template and returning the final +prompt text. The CLI then sends this prompt to the model for execution. This +provides a convenient way to automate and share common workflows. + +## Managing MCP servers with `gemini mcp` + +While you can always configure MCP servers by manually editing your +`settings.json` file, Gemini CLI provides a convenient set of commands to manage +your server configurations programmatically. These commands streamline the +process of adding, listing, and removing MCP servers without needing to directly +edit JSON files. + +### Adding a server (`gemini mcp add`) + +The `add` command configures a new MCP server in your `settings.json`. Based on +the scope (`-s, --scope`), it will be added to either the user config +`~/.gemini/settings.json` or the project config `.gemini/settings.json` file. + +**Command:** + +```bash +gemini mcp add [options] [args...] +``` + +- ``: A unique name for the server. +- ``: The command to execute (for `stdio`) or the URL (for + `http`/`sse`). +- `[args...]`: Optional arguments for a `stdio` command. + +**Options (flags):** + +- `-s, --scope`: Configuration scope (user or project). [default: "project"] +- `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"] +- `-e, --env`: Set environment variables (for example, `-e KEY=value`). +- `-H, --header`: Set HTTP headers for SSE and HTTP transports (for example, + `-H "X-Api-Key: abc123" -H "Authorization: Bearer abc123"`). +- `--timeout`: Set connection timeout in milliseconds. +- `--trust`: Trust the server (bypass all tool call confirmation prompts). +- `--description`: Set the description for the server. +- `--include-tools`: A comma-separated list of tools to include. +- `--exclude-tools`: A comma-separated list of tools to exclude. + +#### Adding an stdio server + +This is the default transport for running local servers. + +```bash +# Basic syntax +gemini mcp add [options] [args...] + +# Example: Adding a local server +gemini mcp add -e API_KEY=123 -e DEBUG=true my-stdio-server /path/to/server arg1 arg2 arg3 + +# Example: Adding a local python server +gemini mcp add python-server python server.py -- --server-arg my-value +``` + +#### Adding an HTTP server + +This transport is for servers that use the streamable HTTP transport. + +```bash +# Basic syntax +gemini mcp add --transport http + +# Example: Adding an HTTP server +gemini mcp add --transport http http-server https://api.example.com/mcp/ + +# Example: Adding an HTTP server with an authentication header +gemini mcp add --transport http --header "Authorization: Bearer abc123" secure-http https://api.example.com/mcp/ +``` + +#### Adding an SSE server + +This transport is for servers that use Server-Sent Events (SSE). + +```bash +# Basic syntax +gemini mcp add --transport sse + +# Example: Adding an SSE server +gemini mcp add --transport sse sse-server https://api.example.com/sse/ + +# Example: Adding an SSE server with an authentication header +gemini mcp add --transport sse --header "Authorization: Bearer abc123" secure-sse https://api.example.com/sse/ +``` + +### Listing servers (`gemini mcp list`) + +To view all MCP servers currently configured, use the `list` command. It +displays each server's name, configuration details, and connection status. This +command has no flags. + +**Command:** + +```bash +gemini mcp list +``` + + +> [!NOTE] +> For security, `stdio` MCP servers (those using the +> `command` property) are only tested and displayed as "Connected" if the +> current folder is trusted. If the folder is untrusted, they will show as +> "Disconnected". Use `gemini trust` to trust the current folder. + +**Example output:** + +```sh +βœ“ stdio-server: command: python3 server.py (stdio) - Connected +βœ“ http-server: https://api.example.com/mcp (http) - Connected +βœ— sse-server: https://api.example.com/sse (sse) - Disconnected +``` + +## Troubleshooting and Diagnostics + +To minimize noise during startup, MCP connection errors for background servers +are "silent by default." If issues are detected during startup, a single +informational hint will be shown: _"MCP issues detected. Run /mcp list for +status."_ + +Detailed, actionable diagnostics for a specific server are automatically +re-enabled when: + +1. You run an interactive command like `/mcp list`, `/mcp auth`, etc. +2. The model attempts to execute a tool from that server. +3. You invoke an MCP prompt from that server. + +You can also use `gemini mcp list` from your shell to see connection errors for +all configured servers. + +### Removing a server (`gemini mcp remove`) + +To delete a server from your configuration, use the `remove` command with the +server's name. + +**Command:** + +```bash +gemini mcp remove +``` + +**Options (flags):** + +- `-s, --scope`: Configuration scope (user or project). [default: "project"] + +**Example:** + +```bash +gemini mcp remove my-server +``` + +This will find and delete the "my-server" entry from the `mcpServers` object in +the appropriate `settings.json` file based on the scope (`-s, --scope`). + +### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`) + +Temporarily disable an MCP server without removing its configuration, or +re-enable a previously disabled server. + +**Commands:** + +```bash +gemini mcp enable [--session] +gemini mcp disable [--session] +``` + +**Options (flags):** + +- `--session`: Apply change only for this session (not persisted to file). + +Disabled servers appear in `/mcp` status as "Disabled" but won't connect or +provide tools. Enablement state is stored in +`~/.gemini/mcp-server-enablement.json`. + +The same commands are available as slash commands during an active session: +`/mcp enable ` and `/mcp disable `. + +## Instructions + +Gemini CLI supports +[MCP server instructions](https://modelcontextprotocol.io/specification/2025-06-18/schema#initializeresult), +which will be appended to the system instructions. diff --git a/docs/tools/memory.md b/docs/tools/memory.md new file mode 100644 index 0000000000000000000000000000000000000000..b09ef2bb2c1d9dee918121211ef064a13f1e5a73 --- /dev/null +++ b/docs/tools/memory.md @@ -0,0 +1,32 @@ +# Memory files + +Gemini CLI persists durable facts, user preferences, and project details by +editing Markdown memory files directly. + +## Technical reference + +The agent routes memories to the appropriate Markdown file: shared project +instructions go in repository `GEMINI.md` files, private project notes go in the +per-project private memory folder, and cross-project personal preferences go in +the global `~/.gemini/GEMINI.md` file. + +## Technical behavior + +- **Storage:** Edits Markdown files with `write_file` or `replace`. +- **Loading:** The stored facts are automatically included in the hierarchical + context system for all future sessions. +- **Format:** Keeps durable instructions concise and avoids duplicating the same + fact across multiple memory tiers. + +## Use cases + +- Persisting user preferences (for example, "I prefer functional programming"). +- Saving project-wide architectural decisions. +- Storing frequently used aliases or system configurations. + +## Next steps + +- Follow the [Memory management guide](../cli/tutorials/memory-management.md) + for practical examples. +- Learn how the [Project context (GEMINI.md)](../cli/gemini-md.md) system loads + this information. diff --git a/docs/tools/planning.md b/docs/tools/planning.md new file mode 100644 index 0000000000000000000000000000000000000000..13e9cd4fd8c4e2c24bdfea1dae2489a6b105d96d --- /dev/null +++ b/docs/tools/planning.md @@ -0,0 +1,62 @@ +# Gemini CLI planning tools + +Planning tools let Gemini CLI switch into a safe, read-only "Plan Mode" for +researching and planning complex changes, and to signal the finalization of a +plan to the user. + +## 1. `enter_plan_mode` (EnterPlanMode) + +`enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called +by the agent when you ask it to "start a plan" using natural language. In this +mode, the agent is restricted to read-only tools to allow for safe exploration +and planning. + + +> [!NOTE] +> This tool is not available when the CLI is in YOLO mode. + +- **Tool name:** `enter_plan_mode` +- **Display name:** Enter Plan Mode +- **File:** `enter-plan-mode.ts` +- **Parameters:** + - `reason` (string, optional): A short reason explaining why the agent is + entering plan mode (for example, "Starting a complex feature + implementation"). +- **Behavior:** + - Switches the CLI's approval mode to `PLAN`. + - Notifies the user that the agent has entered Plan Mode. +- **Output (`llmContent`):** A message indicating the switch, for example, + `Switching to Plan mode.` +- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode. + +## 2. `exit_plan_mode` (ExitPlanMode) + +`exit_plan_mode` signals that the planning phase is complete. It presents the +finalized plan to the user and requests formal approval to start the +implementation. The agent MUST reach an informal agreement with the user in the +chat regarding the proposed strategy BEFORE calling this tool. + +- **Tool name:** `exit_plan_mode` +- **Display name:** Exit Plan Mode +- **File:** `exit-plan-mode.ts` +- **Parameters:** + - `plan_path` (string, required): The path to the finalized Markdown plan + file. This file MUST be located within the project's temporary plans + directory (for example, `~/.gemini/tmp//plans/`). +- **Behavior:** + - Validates that the `plan_path` is within the allowed directory and that the + file exists and has content. + - Presents the plan to the user for formal review. + - If the user approves the plan: + - Switches the CLI's approval mode to the user's chosen approval mode ( + `DEFAULT` or `AUTO_EDIT`). + - Marks the plan as approved for implementation. + - If the user rejects the plan: + - Stays in Plan Mode. + - Returns user feedback to the model to refine the plan. +- **Output (`llmContent`):** + - On approval: A message indicating the plan was approved and the new approval + mode. + - On rejection: A message containing the user's feedback. +- **Confirmation:** Yes. Shows the finalized plan and asks for user formal + approval to proceed with implementation. diff --git a/docs/tools/shell.md b/docs/tools/shell.md new file mode 100644 index 0000000000000000000000000000000000000000..e3df7a4c525131907f58e93e35b5369eb4885f0e --- /dev/null +++ b/docs/tools/shell.md @@ -0,0 +1,241 @@ +# Shell tool (`run_shell_command`) + +The `run_shell_command` tool allows the Gemini model to execute commands +directly on your system's shell. It is the primary mechanism for the agent to +interact with your environment beyond simple file edits. + +## Technical reference + +On Windows, commands execute with `powershell.exe -NoProfile -Command`. On other +platforms, they execute with `bash -c`. + +### Arguments + +- `command` (string, required): The exact shell command to execute. +- `description` (string, optional): A brief description shown to the user for + confirmation. +- `dir_path` (string, optional): The absolute path or relative path from + workspace root where the command runs. +- `is_background` (boolean, optional): Whether to move the process to the + background immediately after starting. + +### Policy engine shorthands + +The [policy engine](../reference/policy-engine.md) provides two convenience +fields for writing rules that target shell commands: + +- `commandPrefix`: Matches if the `command` argument starts with a given string. +- `commandRegex`: Matches if the `command` argument matches a given regular + expression. + +These are syntactic sugar for combining `toolName = "run_shell_command"` with an +`argsPattern` in a policy TOML file. They are **not** arguments of +`run_shell_command` itself. + +For details on writing shell-specific policy rules, see +[Special syntax for `run_shell_command`](../reference/policy-engine.md#special-syntax-for-run_shell_command) +in the policy engine reference. + +### Return values + +The tool returns a JSON object containing: + +- `Command`: The executed string. +- `Directory`: The execution path. +- `Stdout` / `Stderr`: The output streams. +- `Exit Code`: The process return code. +- `Background PIDs`: PIDs of any started background processes. + +## Configuration + +You can configure the behavior of the `run_shell_command` tool by modifying your +`settings.json` file or by using the `/settings` command in Gemini CLI. + +### Enabling interactive commands + +To enable interactive commands, you need to set the +`tools.shell.enableInteractiveShell` setting to `true`. This will use `node-pty` +for shell command execution, which allows for interactive sessions. If +`node-pty` is not available, it will fall back to the `child_process` +implementation, which does not support interactive commands. + +**Example `settings.json`:** + +```json +{ + "tools": { + "shell": { + "enableInteractiveShell": true + } + } +} +``` + +### Showing color in output + +To show color in the shell output, you need to set the `tools.shell.showColor` +setting to `true`. This setting only applies when +`tools.shell.enableInteractiveShell` is enabled. + +**Example `settings.json`:** + +```json +{ + "tools": { + "shell": { + "showColor": true + } + } +} +``` + +### Setting the pager + +You can set a custom pager for the shell output by setting the +`tools.shell.pager` setting. The default pager is `cat`. This setting only +applies when `tools.shell.enableInteractiveShell` is enabled. + +**Example `settings.json`:** + +```json +{ + "tools": { + "shell": { + "pager": "less" + } + } +} +``` + +## Interactive commands + +The `run_shell_command` tool now supports interactive commands by integrating a +pseudo-terminal (pty). This lets you run commands that require real-time user +input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), and +interactive version control operations (`git rebase -i`). + +When an interactive command is running, you can send input to it from the Gemini +CLI. To focus on the interactive shell, press `Tab`. The terminal output, +including complex TUIs, will be rendered correctly. + +## Important notes + +- **Security:** Be cautious when executing commands, especially those + constructed from user input, to prevent security vulnerabilities. +- **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to + determine if a command executed successfully. +- **Background processes:** When a command is run in the background with `&`, + the tool will return immediately and the process will continue to run in the + background. The `Background PIDs` field will contain the process ID of the + background process. + +## Environment variables + +When `run_shell_command` executes a command, it sets the `GEMINI_CLI=1` +environment variable in the subprocess's environment. This allows scripts or +tools to detect if they are being run from within Gemini CLI. + +## Command restrictions + + +> [!WARNING] +> The `tools.core` setting is an **allowlist for _all_ built-in +> tools**, not just shell commands. When you set `tools.core` to any value, +> _only_ the tools explicitly listed will be enabled. This includes all built-in +> tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`, +> `replace`, etc. + +You can restrict the commands that can be executed by the `run_shell_command` +tool by using the `tools.core` and `tools.exclude` settings in your +configuration file. + +- `tools.core`: To restrict `run_shell_command` to a specific set of commands, + add entries to the `core` list under the `tools` category in the format + `run_shell_command()`. For example, + `"tools": {"core": ["run_shell_command(git)"]}` will only allow `git` + commands. Including the generic `run_shell_command` acts as a wildcard, + allowing any command not explicitly blocked. +- `tools.exclude` [DEPRECATED]: To block specific commands, use the + [Policy Engine](../reference/policy-engine.md). Historically, this setting + allowed adding entries to the `exclude` list under the `tools` category in the + format `run_shell_command()`. For example, + `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands. + +The validation logic is designed to be secure and flexible: + +1. **Command chaining disabled**: The tool automatically splits commands + chained with `&&`, `||`, or `;` and validates each part separately. If any + part of the chain is disallowed, the entire command is blocked. +2. **Prefix matching**: The tool uses prefix matching. For example, if you + allow `git`, you can run `git status` or `git log`. +3. **Blocklist precedence**: The `tools.exclude` list is always checked first. + If a command matches a blocked prefix, it will be denied, even if it also + matches an allowed prefix in `tools.core`. + +### Command restriction examples + +**Allow only specific command prefixes** + +To allow only `git` and `npm` commands, and block all others: + +```json +{ + "tools": { + "core": ["run_shell_command(git)", "run_shell_command(npm)"] + } +} +``` + +- `git status`: Allowed +- `npm install`: Allowed +- `ls -l`: Blocked + +**Block specific command prefixes** + +To block `rm` and allow all other commands: + +```json +{ + "tools": { + "core": ["run_shell_command"], + "exclude": ["run_shell_command(rm)"] + } +} +``` + +- `rm -rf /`: Blocked +- `git status`: Allowed +- `npm install`: Allowed + +**Blocklist takes precedence** + +If a command prefix is in both `tools.core` and `tools.exclude`, it will be +blocked. + +- **`tools.shell.enableInteractiveShell`**: (boolean) Uses `node-pty` for + real-time interaction. +- **`tools.shell.showColor`**: (boolean) Preserves ANSI colors in output. +- **`tools.shell.inactivityTimeout`**: (number) Seconds to wait for output + before killing the process. + +### Command restrictions + +You can limit which commands the agent is allowed to request using these +settings: + +- **`tools.core`**: An allowlist of command prefixes (for example, + `["git", "npm test"]`). +- **`tools.exclude`**: A blocklist of command prefixes. + +## Use cases + +- Running build scripts and test suites. +- Initializing or managing version control systems. +- Installing project dependencies. +- Starting development servers or background watchers. + +## Next steps + +- Follow the [Shell commands tutorial](../cli/tutorials/shell-commands.md) for + practical examples. +- Learn about [Sandboxing](../cli/sandbox.md) to isolate command execution. diff --git a/docs/tools/todos.md b/docs/tools/todos.md new file mode 100644 index 0000000000000000000000000000000000000000..d198b872eab3fc9c0dccdef219b0952b1ef97554 --- /dev/null +++ b/docs/tools/todos.md @@ -0,0 +1,36 @@ +# Todo tool (`write_todos`) + +The `write_todos` tool allows the Gemini agent to maintain an internal list of +subtasks for multi-step requests. + +## Technical reference + +The agent uses this tool to manage its execution plan and provide progress +updates to the CLI interface. + +### Arguments + +- `todos` (array of objects, required): The complete list of tasks. Each object + includes: + - `description` (string): Technical description of the task. + - `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or + `blocked`. + +## Technical behavior + +- **Interface:** Updates the progress indicator above the CLI input prompt. +- **Exclusivity:** Only one task can be marked `in_progress` at any time. +- **Persistence:** Todo state is scoped to the current session. +- **Interaction:** Users can toggle the full list view using **Ctrl+T**. + +## Use cases + +- Breaking down a complex feature implementation into manageable steps. +- Coordinating multi-file refactoring tasks. +- Providing visibility into the agent's current focus during long-running tasks. + +## Next steps + +- Follow the [Task planning tutorial](../cli/tutorials/task-planning.md) for + usage details. +- Learn about [Session management](../cli/session-management.md) for context. diff --git a/docs/tools/tracker.md b/docs/tools/tracker.md new file mode 100644 index 0000000000000000000000000000000000000000..387d0bd654a88c747b90543dc4d0110f5e3d5baf --- /dev/null +++ b/docs/tools/tracker.md @@ -0,0 +1,61 @@ +# Tracker tools (`tracker_*`) + + +> [!NOTE] +> This is an experimental feature currently under active development. + +The `tracker_*` tools allow the Gemini agent to maintain an internal, persistent +graph of tasks and dependencies for multi-step requests. This suite of tools +provides a more robust and granular way to manage execution plans than the +legacy `write_todos` tool. + +## Technical reference + +The agent uses these tools to manage its execution plan, decompose complex goals +into actionable sub-tasks, and provide real-time progress updates to the CLI +interface. The task state is stored in the `.gemini/tmp/tracker/` +directory, allowing the agent to manage its plan for the current session. + +### Available Tools + +- `tracker_create_task`: Creates a new task in the tracker. You can specify a + title, description, and task type (`epic`, `task`, `bug`). +- `tracker_update_task`: Updates an existing task's status (`open`, + `in_progress`, `blocked`, `closed`), description, or dependencies. +- `tracker_get_task`: Retrieves the full details of a specific task by its + 6-character hex ID. +- `tracker_list_tasks`: Lists tasks in the tracker, optionally filtered by + status, type, or parent ID. +- `tracker_add_dependency`: Adds a dependency between two tasks, ensuring + topological execution. +- `tracker_visualize`: Renders an ASCII tree visualization of the current task + graph. + +## Technical behavior + +- **Interface:** Updates the progress indicator and task tree above the CLI + input prompt. +- **Persistence:** Task state is saved automatically to the + `.gemini/tmp/tracker/` directory. Task states are session-specific + and do not persist across different sessions. +- **Dependencies:** Tasks can depend on other tasks, forming a directed acyclic + graph (DAG). The agent must resolve dependencies before starting blocked + tasks. +- **Interaction:** Users can view the current state of the tracker by asking the + agent to visualize it, or by running `gemini-cli` commands if implemented. + +## Use cases + +- Coordinating multi-file refactoring projects. +- Breaking down a mission into a hierarchy of epics and tasks for better + visibility. +- Tracking bugs and feature requests directly within the context of an active + codebase. +- Providing visibility into the agent's current focus and remaining work. + +## Next steps + +- Follow the [Task planning tutorial](../cli/tutorials/task-planning.md) for + usage details and migration from the legacy todo list. +- Learn about [Session management](../cli/session-management.md) for context on + persistent state. diff --git a/docs/tools/web-fetch.md b/docs/tools/web-fetch.md new file mode 100644 index 0000000000000000000000000000000000000000..807b77f0c8d4b77180dbc167287ea9aaa0ffabc8 --- /dev/null +++ b/docs/tools/web-fetch.md @@ -0,0 +1,42 @@ +# Web fetch tool (`web_fetch`) + +The `web_fetch` tool allows the Gemini agent to retrieve and process content +from specific URLs provided in your prompt. + +## Technical reference + +The agent uses this tool when you include URLs in your prompt and request +specific operations like summarization or extraction. + +### Arguments + +- `prompt` (string, required): A request containing up to 20 valid URLs + (starting with `http://` or `https://`) and instructions on how to process + them. + +## Technical behavior + +- **Network access:** Destination URLs and resolved IP addresses are validated + to restrict access to private, reserved, loopback, and internal networks + (e.g., RFC 1918 addresses and internal domains). Transport connections are + pinned to the resolved destination IP address. +- **Confirmation:** Triggers a confirmation dialog showing the converted URLs. +- **Plan Mode:** In [Plan Mode](../cli/plan-mode.md), `web_fetch` is available + but always requires explicit user confirmation (`ask_user`). +- **Processing:** Uses the Gemini API's `urlContext` for retrieval. +- **Fallback:** If API access fails, the tool attempts to fetch raw content + directly from your local machine (with destination IP validation and + connection pinning applied). +- **Formatting:** Returns a synthesized response with source attribution. + +## Use cases + +- Summarizing technical articles or blog posts. +- Comparing data between two or more web pages. +- Extracting specific information from a documentation site. + +## Next steps + +- Follow the [Web tools guide](../cli/tutorials/web-tools.md) for practical + usage examples. +- See the [Web search tool reference](./web-search.md) for general queries. diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md new file mode 100644 index 0000000000000000000000000000000000000000..3976b2b7e071e958ae4331e281bdb9ce3ee4bd7a --- /dev/null +++ b/docs/tools/web-search.md @@ -0,0 +1,32 @@ +# Web search tool (`google_web_search`) + +The `google_web_search` tool allows the Gemini agent to retrieve up-to-date +information, news, and facts from the internet via Google Search. + +## Technical reference + +The agent uses this tool when your request requires knowledge of current events +or specific online documentation not available in its internal training data. + +### Arguments + +- `query` (string, required): The search query to be executed. + +## Technical behavior + +- **Grounding:** Returns a generated summary based on search results. +- **Citations:** Includes source URIs and titles for factual grounding. +- **Processing:** The Gemini API processes the search results before returning a + synthesized response to the agent. + +## Use cases + +- Researching the latest version of a software library or API. +- Finding solutions to recent software bugs or security vulnerabilities. +- Retrieving news or documentation updated after the model's knowledge cutoff. + +## Next steps + +- Follow the [Web tools guide](../cli/tutorials/web-tools.md) for practical + usage examples. +- Explore the [Web fetch tool reference](./web-fetch.md) for direct URL access. diff --git a/packages/core/src/__mocks__/fs/promises.ts b/packages/core/src/__mocks__/fs/promises.ts new file mode 100644 index 0000000000000000000000000000000000000000..4238591113b652bbcff356d1b4118f08556a1a4e --- /dev/null +++ b/packages/core/src/__mocks__/fs/promises.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import * as actualFsPromises from 'node:fs/promises'; + +const readFileMock = vi.fn(); + +// Export a control object so tests can access and manipulate the mock +export const mockControl = { + mockReadFile: readFileMock, +}; + +// Export all other functions from the actual fs/promises module +export const { + access, + appendFile, + chmod, + chown, + copyFile, + cp, + lchmod, + lchown, + link, + lstat, + mkdir, + open, + opendir, + readdir, + readlink, + realpath, + rename, + rmdir, + rm, + stat, + symlink, + truncate, + unlink, + utimes, + watch, + writeFile, +} = actualFsPromises; + +// Override readFile with our mock +export const readFile = readFileMock; diff --git a/packages/core/src/agent/agent-session.test.ts b/packages/core/src/agent/agent-session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ee9e4b7f35b94466584d276f37030122af12e13 --- /dev/null +++ b/packages/core/src/agent/agent-session.test.ts @@ -0,0 +1,492 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AgentSession } from './agent-session.js'; +import { MockAgentProtocol } from './mock.js'; +import type { AgentEvent, AgentSend } from './types.js'; + +function makeMessageSend( + text: string, + displayContent?: string, +): Extract { + return { + message: { + content: [{ type: 'text', text }], + ...(displayContent ? { displayContent } : {}), + }, + }; +} + +describe('AgentSession', () => { + it('should passthrough simple methods', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + await session.send({ update: { title: 't' } }); + // update, agent_start, message, agent_end = 4 events + expect(session.events).toHaveLength(4); + + let emitted = false; + session.subscribe(() => { + emitted = true; + }); + protocol.pushResponse([]); + await session.send({ update: { title: 't' } }); + expect(emitted).toBe(true); + + protocol.pushResponse([], { keepOpen: true }); + await session.send({ update: { title: 't' } }); + await session.abort(); + expect( + session.events.some( + (e) => e.type === 'agent_end' && e.reason === 'aborted', + ), + ).toBe(true); + }); + + it('should yield events via sendStream', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + ...makeMessageSend('hi'), + })) { + events.push(event); + } + + // agent_start, agent message, agent_end = 3 events (user message skipped) + expect(events).toHaveLength(3); + expect(events[0].type).toBe('agent_start'); + expect(events[1].type).toBe('message'); + expect((events[1] as AgentEvent<'message'>).role).toBe('agent'); + expect(events[2].type).toBe('agent_end'); + }); + + it('should filter events by streamId in sendStream', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + + const events: AgentEvent[] = []; + const stream = session.sendStream({ update: { title: 'foo' } }); + + for await (const event of stream) { + events.push(event); + } + + expect(events).toHaveLength(3); // agent_start, message, agent_end (update skipped) + const streamId = events[0].streamId; + expect(streamId).not.toBeNull(); + expect(events.every((e) => e.streamId === streamId)).toBe(true); + }); + + it('should handle events arriving before send() resolves', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + update: { title: 'foo' }, + })) { + events.push(event); + } + + expect(events).toHaveLength(3); // agent_start, message, agent_end (update skipped) + expect(events[0].type).toBe('agent_start'); + expect(events[1].type).toBe('message'); + expect(events[2].type).toBe('agent_end'); + }); + + it('should return immediately from sendStream if streamId is null', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // No response queued, so send() returns streamId: null + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + update: { title: 'foo' }, + })) { + events.push(event); + } + + expect(events).toHaveLength(0); + expect(protocol.events).toHaveLength(1); + expect(protocol.events[0].type).toBe('session_update'); + expect(protocol.events[0].streamId).toEqual(expect.any(String)); + }); + + it('should skip events that occur before agent_start', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Custom emission to ensure events happen before agent_start + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + + // We can't easily inject events before agent_start with MockAgentProtocol.pushResponse + // because it emits them all together. + // But we know session_update is emitted first. + + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + ...makeMessageSend('hi'), + })) { + events.push(event); + } + + // The session_update (from the 'hi' message) should be skipped. + expect(events.some((e) => e.type === 'session_update')).toBe(false); + expect(events[0].type).toBe('agent_start'); + }); + + describe('stream()', () => { + it('should replay events after eventId', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Create some events + protocol.pushResponse([{ type: 'message' }]); + await session.send({ update: { title: 't1' } }); + // Wait for events to be emitted + await new Promise((resolve) => setTimeout(resolve, 10)); + + const allEvents = session.events; + expect(allEvents.length).toBeGreaterThan(2); + const eventId = allEvents[1].id; + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ eventId })) { + streamedEvents.push(event); + } + + expect(streamedEvents).toEqual(allEvents.slice(2)); + }); + + it('should complete immediately when resuming from agent_end', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + const { streamId } = await session.send({ + ...makeMessageSend('request'), + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const endEvent = session.events.findLast( + (event): event is AgentEvent<'agent_end'> => + event.type === 'agent_end' && event.streamId === streamId, + ); + expect(endEvent).toBeDefined(); + + const iterator = session + .stream({ eventId: endEvent!.id }) + [Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + }); + + it('should throw for an unknown eventId', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + const iterator = session + .stream({ eventId: 'missing-event' }) + [Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toThrow( + 'Unknown eventId: missing-event', + ); + }); + + it('should throw when resuming from an event before agent_start on a stream with no agent activity', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + const { streamId } = await session.send({ update: { title: 'draft' } }); + expect(streamId).toBeNull(); + + const updateEvent = session.events.find( + (event): event is AgentEvent<'session_update'> => + event.type === 'session_update', + ); + expect(updateEvent).toBeDefined(); + + const iterator = session + .stream({ eventId: updateEvent!.id }) + [Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toThrow( + `Cannot resume from eventId ${updateEvent!.id} before agent_start for stream ${updateEvent!.streamId}`, + ); + }); + + it('should replay from agent_start when resuming from a pre-agent_start event after activity is in history', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + await session.send({ + ...makeMessageSend('request'), + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const userMessage = session.events.find( + (event): event is AgentEvent<'message'> => + event.type === 'message' && event.role === 'user', + ); + expect(userMessage).toBeDefined(); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ eventId: userMessage!.id })) { + streamedEvents.push(event); + } + + expect(streamedEvents.map((event) => event.type)).toEqual([ + 'agent_start', + 'message', + 'agent_end', + ]); + expect(streamedEvents[0]?.streamId).toBe(userMessage!.streamId); + }); + + it('should throw when resuming from a pre-agent_start event before activity is in history', async () => { + const protocol = new MockAgentProtocol([ + { + id: 'e-1', + timestamp: '2026-01-01T00:00:00.000Z', + streamId: 'stream-1', + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'request' }], + }, + ]); + const session = new AgentSession(protocol); + + const iterator = session + .stream({ eventId: 'e-1' }) + [Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toThrow( + 'Cannot resume from eventId e-1 before agent_start for stream stream-1', + ); + }); + + it('should resume from an in-stream event within the same stream only', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'first answer 1' }], + }, + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'first answer 2' }], + }, + ]); + const { streamId: streamId1 } = await session.send({ + ...makeMessageSend('first request'), + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'second answer' }], + }, + ]); + await session.send({ + ...makeMessageSend('second request'), + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const resumeEvent = session.events.find( + (event): event is AgentEvent<'message'> => + event.type === 'message' && + event.streamId === streamId1 && + event.role === 'agent' && + event.content[0]?.type === 'text' && + event.content[0].text === 'first answer 1', + ); + expect(resumeEvent).toBeDefined(); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ eventId: resumeEvent!.id })) { + streamedEvents.push(event); + } + + expect( + streamedEvents.every((event) => event.streamId === streamId1), + ).toBe(true); + expect(streamedEvents.map((event) => event.type)).toEqual([ + 'message', + 'agent_end', + ]); + const resumedMessage = streamedEvents[0] as AgentEvent<'message'>; + expect(resumedMessage.content).toEqual([ + { type: 'text', text: 'first answer 2' }, + ]); + }); + + it('should replay events for streamId starting with agent_start', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const allEvents = session.events; + const startEventIndex = allEvents.findIndex( + (e) => e.type === 'agent_start' && e.streamId === streamId, + ); + expect(startEventIndex).toBeGreaterThan(-1); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ streamId: streamId! })) { + streamedEvents.push(event); + } + + expect(streamedEvents).toEqual(allEvents.slice(startEventIndex)); + }); + + it('should continue listening for active stream after replay', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Start a stream but keep it open + protocol.pushResponse([{ type: 'message' }], { keepOpen: true }); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const streamedEvents: AgentEvent[] = []; + const streamPromise = (async () => { + for await (const event of session.stream({ streamId: streamId! })) { + streamedEvents.push(event); + } + })(); + + // Push more to the stream + await new Promise((resolve) => setTimeout(resolve, 20)); + protocol.pushToStream(streamId!, [{ type: 'message' }], { close: true }); + + await streamPromise; + + const allEvents = session.events; + const startEventIndex = allEvents.findIndex( + (e) => e.type === 'agent_start' && e.streamId === streamId, + ); + expect(streamedEvents).toEqual(allEvents.slice(startEventIndex)); + expect(streamedEvents.at(-1)?.type).toBe('agent_end'); + }); + + it('should not drop agent_end that arrives while replay events are being yielded', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }], { keepOpen: true }); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const iterator = session + .stream({ streamId: streamId! }) + [Symbol.asyncIterator](); + + const first = await iterator.next(); + expect(first.value?.type).toBe('agent_start'); + + protocol.pushToStream(streamId!, [], { close: true }); + + const second = await iterator.next(); + expect(second.value?.type).toBe('message'); + + const third = await iterator.next(); + expect(third.value?.type).toBe('agent_end'); + + const fourth = await iterator.next(); + expect(fourth.done).toBe(true); + }); + + it('should follow an active stream if no options provided', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }], { keepOpen: true }); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const streamedEvents: AgentEvent[] = []; + const streamPromise = (async () => { + for await (const event of session.stream()) { + streamedEvents.push(event); + } + })(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + protocol.pushToStream(streamId!, [{ type: 'message' }], { close: true }); + await streamPromise; + + expect(streamedEvents.length).toBeGreaterThan(0); + expect(streamedEvents.at(-1)?.type).toBe('agent_end'); + }); + + it('should ONLY yield events for specific streamId even if newer streams exist', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Stream 1 + protocol.pushResponse([{ type: 'message' }]); + const { streamId: streamId1 } = await session.send({ + update: { title: 's1' }, + }); + + // Stream 2 + protocol.pushResponse([{ type: 'message' }]); + const { streamId: streamId2 } = await session.send({ + update: { title: 's2' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ streamId: streamId1! })) { + streamedEvents.push(event); + } + + expect(streamedEvents.every((e) => e.streamId === streamId1)).toBe(true); + expect(streamedEvents.some((e) => e.type === 'agent_end')).toBe(true); + expect(streamedEvents.some((e) => e.streamId === streamId2)).toBe(false); + }); + }); +}); diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a4c295fc867f34bbf745031034213cc4ba641ad --- /dev/null +++ b/packages/core/src/agent/agent-session.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AgentProtocol, + AgentSend, + AgentEvent, + Unsubscribe, +} from './types.js'; + +/** + * AgentSession is a wrapper around AgentProtocol that provides a more + * convenient API for consuming agent activity as an AsyncIterable. + */ +export class AgentSession implements AgentProtocol { + private _protocol: AgentProtocol; + + constructor(protocol: AgentProtocol) { + this._protocol = protocol; + } + + async send(payload: AgentSend): Promise<{ streamId: string | null }> { + return this._protocol.send(payload); + } + + subscribe(callback: (event: AgentEvent) => void): Unsubscribe { + return this._protocol.subscribe(callback); + } + + async abort(): Promise { + return this._protocol.abort(); + } + + get events(): readonly AgentEvent[] { + return this._protocol.events; + } + + /** + * Sends a payload to the agent and returns an AsyncIterable that yields + * events for the resulting stream. + * + * @param payload The payload to send to the agent. + */ + async *sendStream(payload: AgentSend): AsyncIterable { + const result = await this._protocol.send(payload); + const streamId = result.streamId; + + if (streamId === null) { + return; + } + + yield* this.stream({ streamId }); + } + + /** + * Returns an AsyncIterable that yields events from the agent session, + * optionally replaying events from history or reattaching to an existing stream. + * + * @param options Options for replaying or reattaching to the event stream. + */ + async *stream( + options: { + eventId?: string; + streamId?: string; + } = {}, + ): AsyncIterable { + let resolve: (() => void) | undefined; + let next = new Promise((res) => { + resolve = res; + }); + + let eventQueue: AgentEvent[] = []; + const earlyEvents: AgentEvent[] = []; + let done = false; + let trackedStreamId = options.streamId; + let started = false; + let agentActivityStarted = false; + + const queueVisibleEvent = (event: AgentEvent): void => { + if (trackedStreamId && event.streamId !== trackedStreamId) { + return; + } + + if (!agentActivityStarted) { + if (event.type !== 'agent_start') { + return; + } + trackedStreamId = event.streamId; + agentActivityStarted = true; + } + + if (!trackedStreamId) { + return; + } + + eventQueue.push(event); + if (event.type === 'agent_end' && event.streamId === trackedStreamId) { + done = true; + } + }; + + // 1. Subscribe early to avoid missing any events that occur during replay setup + const unsubscribe = this._protocol.subscribe((event) => { + if (done) return; + + if (!started) { + earlyEvents.push(event); + return; + } + + queueVisibleEvent(event); + + const currentResolve = resolve; + next = new Promise((r) => { + resolve = r; + }); + currentResolve?.(); + }); + + try { + const currentEvents = this._protocol.events; + let replayStartIndex = -1; + + if (options.eventId) { + const index = currentEvents.findIndex((e) => e.id === options.eventId); + if (index === -1) { + throw new Error(`Unknown eventId: ${options.eventId}`); + } + + const resumeEvent = currentEvents[index]; + trackedStreamId = resumeEvent.streamId; + const firstAgentStartIndex = currentEvents.findIndex( + (event) => + event.type === 'agent_start' && event.streamId === trackedStreamId, + ); + + if (resumeEvent.type === 'agent_end') { + replayStartIndex = index + 1; + agentActivityStarted = true; + done = true; + } else if ( + firstAgentStartIndex !== -1 && + firstAgentStartIndex <= index + ) { + replayStartIndex = index + 1; + agentActivityStarted = true; + } else if (firstAgentStartIndex !== -1) { + // A pre-agent_start cursor can be resumed once the corresponding + // agent activity is already present in history. Because stream() + // yields only agent_start -> agent_end, replay begins at agent_start + // rather than at the original pre-start event. + replayStartIndex = firstAgentStartIndex; + } else { + // Consumers can only resume by eventId once the corresponding stream + // has entered the agent_start -> agent_end lifecycle in history. + // Without a recorded agent_start, this wrapper cannot distinguish + // "agent activity may start later" from "this send was acknowledged + // without agent activity" without risking an infinite wait. + throw new Error( + `Cannot resume from eventId ${options.eventId} before agent_start for stream ${trackedStreamId}`, + ); + } + } else if (options.streamId) { + const index = currentEvents.findIndex( + (e) => e.type === 'agent_start' && e.streamId === options.streamId, + ); + if (index !== -1) { + replayStartIndex = index; + } + } else { + const activeStarts = currentEvents.filter( + (e) => e.type === 'agent_start', + ); + for (let i = activeStarts.length - 1; i >= 0; i--) { + const start = activeStarts[i]; + if ( + !currentEvents.some( + (e) => e.type === 'agent_end' && e.streamId === start.streamId, + ) + ) { + trackedStreamId = start.streamId; + replayStartIndex = currentEvents.findIndex( + (e) => e.id === start.id, + ); + break; + } + } + } + + if (replayStartIndex !== -1) { + for (let i = replayStartIndex; i < currentEvents.length; i++) { + const event = currentEvents[i]; + queueVisibleEvent(event); + if (done) break; + } + } + started = true; + + // Process events that arrived while we were replaying + for (const event of earlyEvents) { + if (done) break; + queueVisibleEvent(event); + } + + while (true) { + if (eventQueue.length > 0) { + const eventsToYield = eventQueue; + eventQueue = []; + for (const event of eventsToYield) { + yield event; + } + continue; + } + + if (done) break; + await next; + } + } finally { + unsubscribe(); + } + } +} diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..acf8a4a329459ab93a1f73c635c05a0b1f0c6d1e --- /dev/null +++ b/packages/core/src/agent/content-utils.test.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + geminiPartsToContentParts, + contentPartsToGeminiParts, + buildToolResponseData, +} from './content-utils.js'; +import type { Part } from '@google/genai'; +import type { ContentPart } from './types.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +describe('geminiPartsToContentParts', () => { + it('converts text parts', () => { + const parts: Part[] = [{ text: 'hello' }]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { type: 'text', text: 'hello' }, + ]); + }); + + it('converts thought parts', () => { + const parts: Part[] = [ + { text: 'thinking...', thought: true, thoughtSignature: 'sig123' }, + ]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { + type: 'thought', + thought: 'thinking...', + thoughtSignature: 'sig123', + }, + ]); + }); + + it('converts thought parts without signature', () => { + const parts: Part[] = [{ text: 'thinking...', thought: true }]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { type: 'thought', thought: 'thinking...' }, + ]); + }); + + it('converts inlineData parts to media', () => { + const parts: Part[] = [ + { inlineData: { data: 'base64data', mimeType: 'image/png' } }, + ]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { type: 'media', data: 'base64data', mimeType: 'image/png' }, + ]); + }); + + it('converts fileData parts to media', () => { + const parts: Part[] = [ + { + fileData: { + fileUri: 'gs://bucket/file.pdf', + mimeType: 'application/pdf', + }, + }, + ]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { + type: 'media', + uri: 'gs://bucket/file.pdf', + mimeType: 'application/pdf', + }, + ]); + }); + + it('skips functionCall parts', () => { + const parts: Part[] = [ + { functionCall: { name: 'myFunc', args: { key: 'value' } } }, + ]; + const result = geminiPartsToContentParts(parts); + expect(result).toEqual([]); + }); + + it('skips functionResponse parts', () => { + const parts: Part[] = [ + { + functionResponse: { + name: 'myFunc', + response: { output: 'result' }, + }, + }, + ]; + const result = geminiPartsToContentParts(parts); + expect(result).toEqual([]); + }); + + it('serializes unknown part types to text with _meta', () => { + const parts: Part[] = [{ unknownField: 'data' } as Part]; + const result = geminiPartsToContentParts(parts); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('text'); + expect(result[0]?._meta).toEqual({ partType: 'unknown' }); + }); + + it('handles empty array', () => { + expect(geminiPartsToContentParts([])).toEqual([]); + }); + + it('handles mixed parts', () => { + const parts: Part[] = [ + { text: 'hello' }, + { inlineData: { data: 'img', mimeType: 'image/jpeg' } }, + { text: 'thought', thought: true }, + ]; + const result = geminiPartsToContentParts(parts); + expect(result).toHaveLength(3); + expect(result[0]?.type).toBe('text'); + expect(result[1]?.type).toBe('media'); + expect(result[2]?.type).toBe('thought'); + }); +}); + +describe('contentPartsToGeminiParts', () => { + it('converts text ContentParts', () => { + const content: ContentPart[] = [{ type: 'text', text: 'hello' }]; + expect(contentPartsToGeminiParts(content)).toEqual([{ text: 'hello' }]); + }); + + it('converts thought ContentParts', () => { + const content: ContentPart[] = [ + { type: 'thought', thought: 'thinking...', thoughtSignature: 'sig' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { text: 'thinking...', thought: true, thoughtSignature: 'sig' }, + ]); + }); + + it('converts thought ContentParts without signature', () => { + const content: ContentPart[] = [ + { type: 'thought', thought: 'thinking...' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { text: 'thinking...', thought: true }, + ]); + }); + + it('converts media ContentParts with data to inlineData', () => { + const content: ContentPart[] = [ + { type: 'media', data: 'base64', mimeType: 'image/png' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { inlineData: { data: 'base64', mimeType: 'image/png' } }, + ]); + }); + + it('converts media ContentParts with uri to fileData', () => { + const content: ContentPart[] = [ + { type: 'media', uri: 'gs://bucket/file', mimeType: 'application/pdf' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { + fileData: { fileUri: 'gs://bucket/file', mimeType: 'application/pdf' }, + }, + ]); + }); + + it('converts reference ContentParts to text', () => { + const content: ContentPart[] = [{ type: 'reference', text: '@file.ts' }]; + expect(contentPartsToGeminiParts(content)).toEqual([{ text: '@file.ts' }]); + }); + + it('handles empty array', () => { + expect(contentPartsToGeminiParts([])).toEqual([]); + }); + + it('skips media parts with no data or uri', () => { + const content: ContentPart[] = [{ type: 'media', mimeType: 'image/png' }]; + expect(contentPartsToGeminiParts(content)).toEqual([]); + }); + + it('defaults mimeType for media with data but no mimeType', () => { + const content: ContentPart[] = [{ type: 'media', data: 'base64data' }]; + const result = contentPartsToGeminiParts(content); + expect(result).toEqual([ + { + inlineData: { + data: 'base64data', + mimeType: 'application/octet-stream', + }, + }, + ]); + }); + + it('serializes unknown ContentPart variants', () => { + // Force an unknown variant past the type system + const content = [ + { type: 'custom_widget', payload: 123 }, + ] as unknown as ContentPart[]; + + const warnSpy = vi.spyOn(debugLogger, 'warn'); + const result = contentPartsToGeminiParts(content); + + expect(warnSpy).toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + text: JSON.stringify({ type: 'custom_widget', payload: 123 }), + }); + + warnSpy.mockRestore(); + }); +}); + +describe('buildToolResponseData', () => { + it('preserves outputFile and contentLength', () => { + const result = buildToolResponseData({ + outputFile: '/tmp/result.txt', + contentLength: 256, + }); + expect(result).toEqual({ + outputFile: '/tmp/result.txt', + contentLength: 256, + }); + }); + + it('returns undefined for empty response', () => { + const result = buildToolResponseData({}); + expect(result).toBeUndefined(); + }); + + it('includes errorType when present', () => { + const result = buildToolResponseData({ + errorType: 'permission_denied', + }); + expect(result).toEqual({ errorType: 'permission_denied' }); + }); + + it('merges data with other fields', () => { + const result = buildToolResponseData({ + data: { custom: 'value' }, + outputFile: '/tmp/file.txt', + }); + expect(result).toEqual({ + custom: 'value', + outputFile: '/tmp/file.txt', + }); + }); +}); diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..667714d778d5476eb96f935784595ff0989e4d19 --- /dev/null +++ b/packages/core/src/agent/content-utils.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; +import type { ContentPart } from './types.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * Converts Gemini API Part objects to framework-agnostic ContentPart objects. + * Handles text, thought, inlineData, fileData parts and serializes unknown + * part types to text to avoid silent data loss. + */ +export function geminiPartsToContentParts(parts: Part[]): ContentPart[] { + const result: ContentPart[] = []; + for (const part of parts) { + if ('text' in part && part.text !== undefined) { + if ('thought' in part && part.thought) { + result.push({ + type: 'thought', + thought: part.text, + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + } else { + result.push({ type: 'text', text: part.text }); + } + } else if ('inlineData' in part && part.inlineData) { + result.push({ + type: 'media', + data: part.inlineData.data, + mimeType: part.inlineData.mimeType, + }); + } else if ('fileData' in part && part.fileData) { + result.push({ + type: 'media', + uri: part.fileData.fileUri, + mimeType: part.fileData.mimeType, + }); + } else if ('functionCall' in part && part.functionCall) { + continue; // Skip function calls, they are emitted as distinct tool_request events + } else if ('functionResponse' in part && part.functionResponse) { + continue; // Skip function responses, they are tied to tool_response events + } else { + // Fallback: serialize any unrecognized part type to text + result.push({ + type: 'text', + text: JSON.stringify(part), + _meta: { partType: 'unknown' }, + }); + } + } + return result; +} + +/** + * Converts framework-agnostic ContentPart objects to Gemini API Part objects. + */ +export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { + const result: Part[] = []; + for (const part of content) { + switch (part.type) { + case 'text': + result.push({ text: part.text }); + break; + case 'thought': + result.push({ + text: part.thought, + thought: true, + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + break; + case 'media': + if (part.data) { + result.push({ + inlineData: { + data: part.data, + mimeType: part.mimeType ?? 'application/octet-stream', + }, + }); + } else if (part.uri) { + result.push({ + fileData: { fileUri: part.uri, mimeType: part.mimeType }, + }); + } + break; + case 'reference': + // References are converted to text for the model + result.push({ text: part.text }); + break; + default: { + const _exhaustiveCheck: never = part; + void _exhaustiveCheck; + debugLogger.warn( + `Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`, + ); + // Serialize unknown ContentPart variants instead of dropping them + result.push({ text: JSON.stringify(part) }); + break; + } + } + } + return result; +} + +/** + * Builds the data record for a tool_response AgentEvent, preserving + * all available metadata from the ToolCallResponseInfo. + */ +export function buildToolResponseData(response: { + data?: Record; + errorType?: string; + outputFile?: string; + contentLength?: number; +}): Record | undefined { + const parts: Record = {}; + if (response.data) Object.assign(parts, response.data); + if (response.errorType) parts['errorType'] = response.errorType; + if (response.outputFile) parts['outputFile'] = response.outputFile; + if (response.contentLength !== undefined) + parts['contentLength'] = response.contentLength; + return Object.keys(parts).length > 0 ? parts : undefined; +} diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b32b447fc2f6a8340632b2f3a32351af05ea084f --- /dev/null +++ b/packages/core/src/agent/event-translator.test.ts @@ -0,0 +1,761 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { FinishReason } from '@google/genai'; +import { ToolErrorType } from '../tools/tool-error.js'; +import { + translateEvent, + createTranslationState, + mapFinishReason, + mapHttpToGrpcStatus, + mapError, + mapUsage, + type TranslationState, +} from './event-translator.js'; +import { GeminiEventType } from '../core/turn.js'; +import type { ServerGeminiStreamEvent } from '../core/turn.js'; +import type { AgentEvent } from './types.js'; + +describe('createTranslationState', () => { + it('creates state with default streamId', () => { + const state = createTranslationState(); + expect(state.streamId).toBeDefined(); + expect(state.streamStartEmitted).toBe(false); + expect(state.model).toBeUndefined(); + expect(state.eventCounter).toBe(0); + expect(state.pendingToolNames.size).toBe(0); + }); + + it('creates state with custom streamId', () => { + const state = createTranslationState('custom-stream'); + expect(state.streamId).toBe('custom-stream'); + }); +}); + +describe('translateEvent', () => { + let state: TranslationState; + + beforeEach(() => { + state = createTranslationState('test-stream'); + }); + + describe('Content events', () => { + it('emits agent_start + message for first content event', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'Hello world', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(2); + expect(result[0]?.type).toBe('agent_start'); + expect(result[1]?.type).toBe('message'); + const msg = result[1] as AgentEvent<'message'>; + expect(msg.role).toBe('agent'); + expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]); + }); + + it('skips agent_start for subsequent content events', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'more text', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('message'); + }); + }); + + describe('Thought events', () => { + it('emits thought content with metadata', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Thought, + value: { subject: 'Planning', description: 'I am thinking...' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const msg = result[0] as AgentEvent<'message'>; + expect(msg.content).toEqual([ + { type: 'thought', thought: 'I am thinking...' }, + ]); + expect(msg._meta?.['subject']).toBe('Planning'); + }); + }); + + describe('ToolCallRequest events', () => { + it('emits tool_request and tracks pending tool name', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'read_file', + args: { path: '/tmp/test' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const req = result[0] as AgentEvent<'tool_request'>; + expect(req.requestId).toBe('call-1'); + expect(req.name).toBe('read_file'); + expect(req.args).toEqual({ path: '/tmp/test' }); + expect(state.pendingToolNames.get('call-1')).toBe('read_file'); + }); + }); + + describe('ToolCallResponse events', () => { + it('emits tool_response with content from responseParts', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-1', 'read_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-1', + responseParts: [{ text: 'file contents' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.requestId).toBe('call-1'); + expect(resp.name).toBe('read_file'); + expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]); + expect(resp.isError).toBe(false); + expect(state.pendingToolNames.has('call-1')).toBe(false); + }); + + it('uses error.message for content when tool errored', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-2', 'write_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-2', + responseParts: [{ text: 'stale parts' }], + resultDisplay: 'Permission denied', + error: new Error('Permission denied to write'), + errorType: ToolErrorType.PERMISSION_DENIED, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.isError).toBe(true); + // Should use error.message, not responseParts + expect(resp.content).toEqual([ + { type: 'text', text: 'Permission denied to write' }, + ]); + expect(resp.display?.result).toEqual({ + type: 'text', + text: 'Permission denied', + }); + expect(resp.data).toEqual({ errorType: 'permission_denied' }); + }); + + it('uses "unknown" name for untracked tool calls', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'untracked', + responseParts: [{ text: 'data' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.name).toBe('unknown'); + }); + + it('stringifies object resultDisplay correctly', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-3', 'diff_tool'); + const objectDisplay = { + fileDiff: '@@ -1 +1 @@\n-a\n+b', + fileName: 'test.txt', + filePath: '/tmp/test.txt', + originalContent: 'a', + newContent: 'b', + }; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-3', + responseParts: [{ text: 'diff result' }], + resultDisplay: objectDisplay, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.display?.result).toEqual({ + type: 'diff', + path: '/tmp/test.txt', + beforeText: 'a', + afterText: 'b', + }); + }); + + it('passes through string resultDisplay as-is', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-4', 'shell'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-4', + responseParts: [{ text: 'output' }], + resultDisplay: 'Command output text', + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.display?.result).toEqual({ + type: 'text', + text: 'Command output text', + }); + }); + + it('preserves outputFile and contentLength in data', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-5', 'write_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-5', + responseParts: [{ text: 'written' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + outputFile: '/tmp/out.txt', + contentLength: 42, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.data?.['outputFile']).toBe('/tmp/out.txt'); + expect(resp.data?.['contentLength']).toBe(42); + }); + + it('handles multi-part responses (text + inlineData)', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-6', 'screenshot'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-6', + responseParts: [ + { text: 'Here is the screenshot' }, + { inlineData: { data: 'base64img', mimeType: 'image/png' } }, + ], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.content).toEqual([ + { type: 'text', text: 'Here is the screenshot' }, + { type: 'media', data: 'base64img', mimeType: 'image/png' }, + ]); + expect(resp.isError).toBe(false); + }); + }); + + describe('Error events', () => { + it('emits error event for structured errors', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { error: { message: 'Rate limited', status: 429 } }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('RESOURCE_EXHAUSTED'); + expect(err.message).toBe('Rate limited'); + expect(err.fatal).toBe(true); + }); + + it('emits error event for Error instances', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { error: new Error('Something broke') }, + }; + const result = translateEvent(event, state); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Something broke'); + }); + }); + + describe('ModelInfo events', () => { + it('emits agent_start and session_update when no stream started yet', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ModelInfo, + value: 'gemini-2.5-pro', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(2); + expect(result[0]?.type).toBe('agent_start'); + expect(result[1]?.type).toBe('session_update'); + const sessionUpdate = result[1] as AgentEvent<'session_update'>; + expect(sessionUpdate.model).toBe('gemini-2.5-pro'); + expect(state.model).toBe('gemini-2.5-pro'); + expect(state.streamStartEmitted).toBe(true); + }); + + it('emits session_update when stream already started', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ModelInfo, + value: 'gemini-2.5-flash', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('session_update'); + }); + }); + + describe('AgentExecutionStopped events', () => { + it('emits agent_end with the final stop message in data.message', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionStopped, + value: { + reason: 'before_model', + systemMessage: 'Stopped by hook', + contextCleared: true, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.type).toBe('agent_end'); + expect(streamEnd.reason).toBe('completed'); + expect(streamEnd.data).toEqual({ message: 'Stopped by hook' }); + }); + + it('uses reason when systemMessage is not set', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'hook' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.data).toEqual({ message: 'hook' }); + }); + }); + + describe('AgentExecutionBlocked events', () => { + it('emits non-fatal error event (non-terminal, stream continues)', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Policy violation' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.type).toBe('error'); + expect(err.fatal).toBe(false); + expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED'); + expect(err.message).toBe('Policy violation'); + }); + + it('uses systemMessage in the final error message when available', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionBlocked, + value: { + reason: 'hook_blocked', + systemMessage: 'Blocked by policy hook', + contextCleared: true, + }, + }; + const result = translateEvent(event, state); + const err = result[0] as AgentEvent<'error'>; + expect(err.message).toBe('Blocked by policy hook'); + }); + }); + + describe('LoopDetected events', () => { + it('emits a non-fatal warning error event', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.LoopDetected, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('error'); + const loopWarning = result[0] as AgentEvent<'error'>; + expect(loopWarning.fatal).toBe(false); + expect(loopWarning.message).toBe('Loop detected, stopping execution'); + expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED'); + }); + }); + + describe('MaxSessionTurns events', () => { + it('emits agent_end with max_turns', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.MaxSessionTurns, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.type).toBe('agent_end'); + expect(streamEnd.reason).toBe('max_turns'); + expect(streamEnd.data).toEqual({ code: 'MAX_TURNS_EXCEEDED' }); + }); + }); + + describe('Finished events', () => { + it('emits usage for STOP', () => { + state.streamStartEmitted = true; + state.model = 'gemini-2.5-pro'; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { + reason: FinishReason.STOP, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + }, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + + const usage = result[0] as AgentEvent<'usage'>; + expect(usage.model).toBe('gemini-2.5-pro'); + expect(usage.inputTokens).toBe(100); + expect(usage.outputTokens).toBe(50); + expect(usage.cachedTokens).toBe(10); + }); + + it('emits nothing when no usage metadata is present', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: undefined }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(0); + }); + }); + + describe('Citation events', () => { + it('emits message with citation meta', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Citation, + value: 'Source: example.com', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const msg = result[0] as AgentEvent<'message'>; + expect(msg.content).toEqual([ + { type: 'text', text: 'Source: example.com' }, + ]); + expect(msg._meta?.['citation']).toBe(true); + }); + }); + + describe('UserCancelled events', () => { + it('emits agent_end with reason aborted', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.UserCancelled, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const end = result[0] as AgentEvent<'agent_end'>; + expect(end.type).toBe('agent_end'); + expect(end.reason).toBe('aborted'); + }); + }); + + describe('ContextWindowWillOverflow events', () => { + it('emits fatal error', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ContextWindowWillOverflow, + value: { + estimatedRequestTokenCount: 150000, + remainingTokenCount: 10000, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('RESOURCE_EXHAUSTED'); + expect(err.fatal).toBe(true); + expect(err.message).toContain('150000'); + expect(err.message).toContain('10000'); + }); + }); + + describe('InvalidStream events', () => { + it('emits fatal error with specific message from event', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Empty response'); + expect(err.fatal).toBe(true); + expect(err._meta?.['code']).toBe('INVALID_STREAM'); + expect(err._meta?.['errorType']).toBe('NO_RESPONSE_TEXT'); + expect(err._meta?.['rawMessage']).toBe('Empty response'); + }); + + it('falls back to default message when message is missing', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: '', + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Invalid stream received from model'); + expect(err.fatal).toBe(true); + }); + }); + + describe('Events with no output', () => { + it('returns empty for Retry', () => { + const result = translateEvent({ type: GeminiEventType.Retry }, state); + expect(result).toEqual([]); + }); + + it('returns empty for ChatCompressed with null', () => { + const result = translateEvent( + { type: GeminiEventType.ChatCompressed, value: null }, + state, + ); + expect(result).toEqual([]); + }); + + it('returns empty for ToolCallConfirmation', () => { + // ToolCallConfirmation is skipped in non-interactive mode (elicitations + // are deferred to the interactive runtime adaptation). + const event = { + type: GeminiEventType.ToolCallConfirmation, + value: { + request: { + callId: 'c1', + name: 'tool', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' }, + }, + } as ServerGeminiStreamEvent; + const result = translateEvent(event, state); + expect(result).toEqual([]); + }); + }); + + describe('Event IDs', () => { + it('generates sequential IDs', () => { + state.streamStartEmitted = true; + const e1 = translateEvent( + { type: GeminiEventType.Content, value: 'a' }, + state, + ); + const e2 = translateEvent( + { type: GeminiEventType.Content, value: 'b' }, + state, + ); + expect(e1[0]?.id).toBe('test-stream-0'); + expect(e2[0]?.id).toBe('test-stream-1'); + }); + + it('includes streamId in events', () => { + const events = translateEvent( + { type: GeminiEventType.Content, value: 'hi' }, + state, + ); + for (const e of events) { + expect(e.streamId).toBe('test-stream'); + } + }); + }); +}); + +describe('mapFinishReason', () => { + it('maps STOP to completed', () => { + expect(mapFinishReason(FinishReason.STOP)).toBe('completed'); + }); + + it('maps undefined to completed', () => { + expect(mapFinishReason(undefined)).toBe('completed'); + }); + + it('maps MAX_TOKENS to max_budget', () => { + expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget'); + }); + + it('maps SAFETY to refusal', () => { + expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal'); + }); + + it('maps MALFORMED_FUNCTION_CALL to failed', () => { + expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe( + 'failed', + ); + }); + + it('maps RECITATION to refusal', () => { + expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal'); + }); + + it('maps LANGUAGE to refusal', () => { + expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal'); + }); + + it('maps BLOCKLIST to refusal', () => { + expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal'); + }); + + it('maps OTHER to failed', () => { + expect(mapFinishReason(FinishReason.OTHER)).toBe('failed'); + }); + + it('maps PROHIBITED_CONTENT to refusal', () => { + expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal'); + }); + + it('maps IMAGE_SAFETY to refusal', () => { + expect(mapFinishReason(FinishReason.IMAGE_SAFETY)).toBe('refusal'); + }); + + it('maps IMAGE_PROHIBITED_CONTENT to refusal', () => { + expect(mapFinishReason(FinishReason.IMAGE_PROHIBITED_CONTENT)).toBe( + 'refusal', + ); + }); + + it('maps UNEXPECTED_TOOL_CALL to failed', () => { + expect(mapFinishReason(FinishReason.UNEXPECTED_TOOL_CALL)).toBe('failed'); + }); + + it('maps NO_IMAGE to failed', () => { + expect(mapFinishReason(FinishReason.NO_IMAGE)).toBe('failed'); + }); +}); + +describe('mapHttpToGrpcStatus', () => { + it('maps 400 to INVALID_ARGUMENT', () => { + expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT'); + }); + + it('maps 401 to UNAUTHENTICATED', () => { + expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED'); + }); + + it('maps 429 to RESOURCE_EXHAUSTED', () => { + expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED'); + }); + + it('maps undefined to INTERNAL', () => { + expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL'); + }); + + it('maps unknown codes to INTERNAL', () => { + expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL'); + }); +}); + +describe('mapError', () => { + it('maps structured errors with status', () => { + const result = mapError({ message: 'Rate limit', status: 429 }); + expect(result.status).toBe('RESOURCE_EXHAUSTED'); + expect(result.message).toBe('Rate limit'); + expect(result.fatal).toBe(true); + expect(result._meta?.['status']).toBe(429); + expect(result._meta?.['rawError']).toEqual({ + message: 'Rate limit', + status: 429, + }); + }); + + it('maps Error instances', () => { + const result = mapError(new Error('Something failed')); + expect(result.status).toBe('INTERNAL'); + expect(result.message).toBe('Something failed'); + }); + + it('preserves error name in _meta', () => { + class CustomError extends Error { + constructor(msg: string) { + super(msg); + } + } + const result = mapError(new CustomError('test')); + expect(result._meta?.['errorName']).toBe('CustomError'); + }); + + it('maps non-Error values to string', () => { + const result = mapError('raw string error'); + expect(result.message).toBe('raw string error'); + expect(result.status).toBe('INTERNAL'); + }); +}); + +describe('mapUsage', () => { + it('maps all fields', () => { + const result = mapUsage( + { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 25, + }, + 'gemini-2.5-pro', + ); + expect(result).toEqual({ + model: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 50, + cachedTokens: 25, + }); + }); + + it('uses "unknown" for missing model', () => { + const result = mapUsage({}); + expect(result.model).toBe('unknown'); + }); +}); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts new file mode 100644 index 0000000000000000000000000000000000000000..10a5149a2be636e4208593d39c0ae85aaac1a990 --- /dev/null +++ b/packages/core/src/agent/event-translator.ts @@ -0,0 +1,472 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Pure, stateless-per-call translation functions that convert + * ServerGeminiStreamEvent objects into AgentEvent objects. + * + * No side effects, no generators. Each call to `translateEvent` takes an event + * and mutable TranslationState, returning zero or more AgentEvents. + */ + +import type { FinishReason } from '@google/genai'; +import { GeminiEventType } from '../core/turn.js'; +import type { + ServerGeminiStreamEvent, + StructuredError, + GeminiFinishedEventValue, +} from '../core/turn.js'; +import type { + AgentEvent, + StreamEndReason, + ErrorData, + Usage, + AgentEventType, + ToolDisplay, +} from './types.js'; +import { + geminiPartsToContentParts, + buildToolResponseData, +} from './content-utils.js'; +import { toolResultDisplayToDisplayContent } from './tool-display-utils.js'; + +// --------------------------------------------------------------------------- +// Translation State +// --------------------------------------------------------------------------- + +export interface TranslationState { + streamId: string; + streamStartEmitted: boolean; + model: string | undefined; + eventCounter: number; + /** Tracks callId β†’ tool name from requests so responses can reference the name. */ + pendingToolNames: Map; +} + +export function createTranslationState(streamId?: string): TranslationState { + return { + streamId: streamId ?? crypto.randomUUID(), + streamStartEmitted: false, + model: undefined, + eventCounter: 0, + pendingToolNames: new Map(), + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEvent( + type: T, + state: TranslationState, + payload: Partial>, +): AgentEvent { + const id = `${state.streamId}-${state.eventCounter++}`; + // TypeScript cannot preserve the specific discriminated union member across + // this generic object assembly, so keep the narrowing local to the event + // constructor boundary. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + ...payload, + id, + timestamp: new Date().toISOString(), + streamId: state.streamId, + type, + } as AgentEvent; +} + +function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void { + if (!state.streamStartEmitted) { + out.push(makeEvent('agent_start', state, {})); + state.streamStartEmitted = true; + } +} + +// --------------------------------------------------------------------------- +// Core Translator +// --------------------------------------------------------------------------- + +/** + * Translates a single ServerGeminiStreamEvent into zero or more AgentEvents. + * Mutates `state` (counter, flags) as a side effect. + */ +export function translateEvent( + event: ServerGeminiStreamEvent, + state: TranslationState, +): AgentEvent[] { + const out: AgentEvent[] = []; + + switch (event.type) { + case GeminiEventType.ModelInfo: + state.model = event.value; + ensureStreamStart(state, out); + out.push(makeEvent('session_update', state, { model: event.value })); + break; + + case GeminiEventType.Content: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'text', text: event.value }], + }), + ); + break; + + case GeminiEventType.Thought: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'thought', thought: event.value.description }], + _meta: event.value.subject + ? { source: 'agent', subject: event.value.subject } + : { source: 'agent' }, + }), + ); + break; + + case GeminiEventType.Citation: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'text', text: event.value }], + _meta: { source: 'agent', citation: true }, + }), + ); + break; + + case GeminiEventType.Finished: + handleFinished(event.value, state, out); + break; + + case GeminiEventType.Error: + handleError(event.value.error, state, out); + break; + + case GeminiEventType.UserCancelled: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'aborted', + }), + ); + break; + + case GeminiEventType.MaxSessionTurns: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'max_turns', + data: { + code: 'MAX_TURNS_EXCEEDED', + }, + }), + ); + break; + + case GeminiEventType.LoopDetected: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'INTERNAL', + message: 'Loop detected, stopping execution', + fatal: false, + _meta: { code: 'LOOP_DETECTED' }, + }), + ); + break; + + case GeminiEventType.ContextWindowWillOverflow: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'RESOURCE_EXHAUSTED', + message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`, + fatal: true, + }), + ); + break; + + case GeminiEventType.AgentExecutionStopped: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'completed', + data: { + message: event.value.systemMessage?.trim() || event.value.reason, + }, + }), + ); + break; + + case GeminiEventType.AgentExecutionBlocked: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'PERMISSION_DENIED', + message: event.value.systemMessage?.trim() || event.value.reason, + fatal: false, + _meta: { code: 'AGENT_EXECUTION_BLOCKED' }, + }), + ); + break; + + case GeminiEventType.InvalidStream: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'INTERNAL', + message: + event.value?.message?.trim() || + 'Invalid stream received from model', + fatal: true, + _meta: { + code: 'INVALID_STREAM', + errorType: event.value?.type, + rawMessage: event.value?.message, + }, + }), + ); + break; + + case GeminiEventType.ToolCallRequest: + ensureStreamStart(state, out); + state.pendingToolNames.set(event.value.callId, event.value.name); + out.push( + makeEvent('tool_request', state, { + requestId: event.value.callId, + name: event.value.name, + args: event.value.args, + display: event.value.display, + }), + ); + break; + + case GeminiEventType.ToolCallResponse: { + ensureStreamStart(state, out); + const data = buildToolResponseData(event.value); + const display: ToolDisplay | undefined = + event.value.display ?? + (event.value.resultDisplay + ? { + result: toolResultDisplayToDisplayContent( + event.value.resultDisplay, + ), + } + : undefined); + out.push( + makeEvent('tool_response', state, { + requestId: event.value.callId, + name: state.pendingToolNames.get(event.value.callId) ?? 'unknown', + content: event.value.error + ? [{ type: 'text', text: event.value.error.message }] + : geminiPartsToContentParts(event.value.responseParts), + isError: event.value.error !== undefined, + ...(display ? { display } : {}), + ...(data ? { data } : {}), + }), + ); + state.pendingToolNames.delete(event.value.callId); + break; + } + + case GeminiEventType.ToolCallConfirmation: + // Elicitations are handled separately by the session layer + break; + + // Internal concerns β€” no AgentEvent emitted + case GeminiEventType.ChatCompressed: + case GeminiEventType.Retry: + break; + + default: + ((x: never) => { + throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); + })(event); + } + + return out; +} + +// --------------------------------------------------------------------------- +// Finished Event Handling +// --------------------------------------------------------------------------- + +function handleFinished( + value: GeminiFinishedEventValue, + state: TranslationState, + out: AgentEvent[], +): void { + if (value.usageMetadata) { + ensureStreamStart(state, out); + const usage = mapUsage(value.usageMetadata, state.model); + out.push(makeEvent('usage', state, usage)); + } +} + +// --------------------------------------------------------------------------- +// Error Handling +// --------------------------------------------------------------------------- + +function handleError( + error: unknown, + state: TranslationState, + out: AgentEvent[], +): void { + ensureStreamStart(state, out); + + const mapped = mapError(error); + out.push(makeEvent('error', state, mapped)); +} + +// --------------------------------------------------------------------------- +// Public Mapping Functions +// --------------------------------------------------------------------------- + +/** + * Maps a Gemini FinishReason to an AgentEnd reason. + */ +export function mapFinishReason( + reason: FinishReason | undefined, +): StreamEndReason { + if (!reason) return 'completed'; + + switch (reason) { + case 'STOP': + case 'FINISH_REASON_UNSPECIFIED': + return 'completed'; + case 'MAX_TOKENS': + return 'max_budget'; + case 'SAFETY': + case 'RECITATION': + case 'LANGUAGE': + case 'BLOCKLIST': + case 'PROHIBITED_CONTENT': + case 'SPII': + case 'IMAGE_SAFETY': + case 'IMAGE_PROHIBITED_CONTENT': + return 'refusal'; + case 'MALFORMED_FUNCTION_CALL': + case 'OTHER': + case 'UNEXPECTED_TOOL_CALL': + case 'NO_IMAGE': + return 'failed'; + default: + return 'failed'; + } +} + +/** + * Maps an HTTP status code to a gRPC-style status string. + */ +export function mapHttpToGrpcStatus( + httpStatus: number | undefined, +): ErrorData['status'] { + if (httpStatus === undefined) return 'INTERNAL'; + + switch (httpStatus) { + case 400: + return 'INVALID_ARGUMENT'; + case 401: + return 'UNAUTHENTICATED'; + case 403: + return 'PERMISSION_DENIED'; + case 404: + return 'NOT_FOUND'; + case 409: + return 'ALREADY_EXISTS'; + case 429: + return 'RESOURCE_EXHAUSTED'; + case 500: + return 'INTERNAL'; + case 501: + return 'UNIMPLEMENTED'; + case 503: + return 'UNAVAILABLE'; + case 504: + return 'DEADLINE_EXCEEDED'; + default: + return 'INTERNAL'; + } +} + +/** + * Maps a StructuredError (or unknown error value) to an ErrorData payload. + * Preserves selected error metadata in _meta and includes raw structured + * errors for lossless debugging. + */ +export function mapError( + error: unknown, +): ErrorData & { _meta?: Record } { + const meta: Record = {}; + + if (error instanceof Error) { + meta['errorName'] = error.constructor.name; + if ('exitCode' in error && typeof error.exitCode === 'number') { + meta['exitCode'] = error.exitCode; + } + if ('code' in error) { + meta['code'] = error.code; + } + } + + if (isStructuredError(error)) { + const structuredMeta = { ...meta, rawError: error, status: error.status }; + return { + status: mapHttpToGrpcStatus(error.status), + message: error.message, + fatal: true, + _meta: structuredMeta, + }; + } + + if (error instanceof Error) { + return { + status: 'INTERNAL', + message: error.message, + fatal: true, + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), + }; + } + + return { + status: 'INTERNAL', + message: String(error), + fatal: true, + }; +} + +function isStructuredError(error: unknown): error is StructuredError { + return ( + typeof error === 'object' && + error !== null && + 'status' in error && + 'message' in error && + typeof error.message === 'string' + ); +} + +/** + * Maps Gemini usageMetadata to Usage. + */ +export function mapUsage( + metadata: { + promptTokenCount?: number; + candidatesTokenCount?: number; + cachedContentTokenCount?: number; + }, + model?: string, +): Usage { + return { + model: model ?? 'unknown', + inputTokens: metadata.promptTokenCount, + outputTokens: metadata.candidatesTokenCount, + cachedTokens: metadata.cachedContentTokenCount, + }; +} diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..03989bd85b6e2603dca0fc7d14350c784aa81663 --- /dev/null +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -0,0 +1,1510 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { FinishReason } from '@google/genai'; +import { LegacyAgentSession } from './legacy-agent-session.js'; +import type { LegacyAgentSessionDeps } from './legacy-agent-session.js'; +import { GeminiEventType } from '../core/turn.js'; +import type { ServerGeminiStreamEvent } from '../core/turn.js'; +import type { AgentEvent, AgentSend } from './types.js'; +import { ToolErrorType } from '../tools/tool-error.js'; +import type { + CompletedToolCall, + ToolCallRequestInfo, +} from '../scheduler/types.js'; +import { CoreToolCallStatus } from '../scheduler/types.js'; +import type { GeminiClient } from '../core/client.js'; +import type { Scheduler } from '../scheduler/scheduler.js'; +import type { Config } from '../config/config.js'; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +function createMockDeps( + overrides?: Partial, +): Required { + const mockClient = { + sendMessageStream: vi.fn(), + getChat: vi.fn().mockReturnValue({ + recordCompletedToolCalls: vi.fn(), + }), + getCurrentSequenceModel: vi.fn().mockReturnValue(null), + }; + + const mockScheduler = { + schedule: vi.fn().mockResolvedValue([]), + }; + + const mockConfig = { + getMaxSessionTurns: vi.fn().mockReturnValue(-1), + getModel: vi.fn().mockReturnValue('gemini-2.5-pro'), + getGeminiClient: vi.fn().mockReturnValue(mockClient), + getMessageBus: vi.fn().mockImplementation(() => ({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + })), + }; + + return { + client: mockClient as unknown as GeminiClient, + scheduler: mockScheduler as unknown as Scheduler, + config: mockConfig as unknown as Config, + promptId: 'test-prompt', + streamId: 'test-stream', + getPreferredEditor: vi.fn().mockReturnValue(undefined), + ...overrides, + } as Required; +} + +async function* makeStream( + events: ServerGeminiStreamEvent[], +): AsyncGenerator { + for (const event of events) { + yield event; + } +} + +function makeToolRequest(callId: string, name: string): ToolCallRequestInfo { + return { + callId, + name, + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }; +} + +function makeMessageSend( + text: string, + displayContent?: string, +): Extract { + return { + message: { + content: [{ type: 'text', text }], + ...(displayContent ? { displayContent } : {}), + }, + }; +} + +function makeCompletedToolCall( + callId: string, + name: string, + responseText: string, +): CompletedToolCall { + return { + status: CoreToolCallStatus.Success, + request: makeToolRequest(callId, name), + response: { + callId, + responseParts: [{ text: responseText }], + resultDisplay: responseText, + display: { + result: { type: 'text', text: responseText }, + }, + error: undefined, + errorType: undefined, + }, + + tool: {} as CompletedToolCall extends { tool: infer T } ? T : never, + + invocation: {} as CompletedToolCall extends { invocation: infer T } + ? T + : never, + } as CompletedToolCall; +} + +async function collectEvents( + session: LegacyAgentSession, + options?: { streamId?: string; eventId?: string }, +): Promise { + const events: AgentEvent[] = []; + const streamOptions = + options?.eventId || options?.streamId ? options : undefined; + + for await (const event of streamOptions + ? session.stream(streamOptions) + : session.stream()) { + events.push(event); + } + return events; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LegacyAgentSession', () => { + let deps: Required; + + beforeEach(() => { + deps = createMockDeps(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + describe('send', () => { + it('returns streamId', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.Content, value: 'hello' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const result = await session.send(makeMessageSend('hi')); + + expect(result.streamId).toBe('test-stream'); + }); + + it('records the sent user message in the trajectory before send resolves', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const { streamId } = await session.send({ + message: { + content: [{ type: 'text', text: 'hi' }], + displayContent: 'raw input', + }, + _meta: { source: 'user-test' }, + }); + + const userMessage = session.events.find( + (e): e is AgentEvent<'message'> => + e.type === 'message' && e.role === 'user' && e.streamId === streamId, + ); + expect(userMessage?.content).toEqual([ + { type: 'text', text: 'raw input' }, + ]); + expect(userMessage?._meta).toEqual({ source: 'user-test' }); + await vi.advanceTimersByTimeAsync(0); + expect(sendMock).toHaveBeenCalledWith( + [{ text: 'hi' }], + expect.any(AbortSignal), + 'test-prompt', + undefined, + 'raw input', + ); + + await collectEvents(session, { streamId: streamId ?? undefined }); + }); + + it('returns streamId before emitting agent_start', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const liveEvents: AgentEvent[] = []; + session.subscribe((event) => { + liveEvents.push(event); + }); + + const { streamId } = await session.send(makeMessageSend('hi')); + + expect(streamId).toBe('test-stream'); + expect(liveEvents.some((event) => event.type === 'agent_start')).toBe( + false, + ); + + await collectEvents(session, { streamId: streamId ?? undefined }); + expect(liveEvents.some((event) => event.type === 'agent_start')).toBe( + true, + ); + }); + + it('throws for non-message payloads', async () => { + const session = new LegacyAgentSession(deps); + await expect(session.send({ update: { title: 'test' } })).rejects.toThrow( + 'only supports message sends', + ); + }); + + it('throws if send is called while a stream is active', async () => { + let resolveHang: (() => void) | undefined; + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + (async function* () { + await new Promise((resolve) => { + resolveHang = resolve; + }); + yield { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + } as ServerGeminiStreamEvent; + })(), + ); + + const session = new LegacyAgentSession(deps); + const { streamId } = await session.send(makeMessageSend('first')); + await vi.advanceTimersByTimeAsync(0); + + await expect(session.send(makeMessageSend('second'))).rejects.toThrow( + 'cannot be called while a stream is active', + ); + + resolveHang?.(); + await collectEvents(session, { streamId: streamId ?? undefined }); + }); + + it('creates a new streamId after the previous stream completes', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'first response' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ) + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'second response' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const first = await session.send(makeMessageSend('first')); + const firstEvents = await collectEvents(session, { + streamId: first.streamId ?? undefined, + }); + + const second = await session.send(makeMessageSend('second')); + const secondEvents = await collectEvents(session, { + streamId: second.streamId ?? undefined, + }); + const userMessages = session.events.filter( + (e): e is AgentEvent<'message'> => + e.type === 'message' && e.role === 'user', + ); + + expect(first.streamId).not.toBe(second.streamId); + expect( + userMessages.some( + (e) => + e.streamId === first.streamId && + e.content[0]?.type === 'text' && + e.content[0].text === 'first', + ), + ).toBe(true); + expect( + userMessages.some( + (e) => + e.streamId === second.streamId && + e.content[0]?.type === 'text' && + e.content[0].text === 'second', + ), + ).toBe(true); + expect(firstEvents.some((e) => e.type === 'agent_end')).toBe(true); + expect(secondEvents.some((e) => e.type === 'agent_end')).toBe(true); + }); + }); + + describe('stream - basic flow', () => { + it('emits agent_start, content messages, and agent_end', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const types = events.map((e) => e.type); + expect(types).toContain('agent_start'); + expect(types).toContain('message'); + expect(types).toContain('agent_end'); + + const messages = events.filter( + (e): e is AgentEvent<'message'> => + e.type === 'message' && e.role === 'agent', + ); + expect(messages).toHaveLength(2); + expect(messages[0]?.content).toEqual([{ type: 'text', text: 'Hello' }]); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('completed'); + }); + }); + + describe('stream - tool calls', () => { + it('handles a tool call round-trip', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + // First turn: model requests a tool + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'read_file'), + }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + // Second turn: model provides final answer + sendMock.mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'Done!' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([ + makeCompletedToolCall('call-1', 'read_file', 'file contents'), + ]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('read a file')); + const events = await collectEvents(session); + + const types = events.map((e) => e.type); + expect(types).toContain('tool_request'); + expect(types).toContain('tool_response'); + expect(types).toContain('agent_end'); + + const toolReq = events.find( + (e): e is AgentEvent<'tool_request'> => e.type === 'tool_request', + ); + expect(toolReq?.name).toBe('read_file'); + + const toolResp = events.find( + (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response', + ); + expect(toolResp?.name).toBe('read_file'); + expect(toolResp?.display).toEqual( + expect.objectContaining({ + name: 'read_file', + result: { type: 'text', text: 'file contents' }, + }), + ); + expect(toolResp?.content).toEqual([ + { type: 'text', text: 'file contents' }, + ]); + expect(toolResp?.isError).toBe(false); + + // Should have called sendMessageStream twice + expect(sendMock).toHaveBeenCalledTimes(2); + }); + + it('handles tool errors and sends error message in content', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'write_file'), + }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + sendMock.mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'Failed' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const errorToolCall: CompletedToolCall = { + status: CoreToolCallStatus.Error, + request: makeToolRequest('call-1', 'write_file'), + response: { + callId: 'call-1', + responseParts: [{ text: 'stale' }], + resultDisplay: 'Error display', + error: new Error('Permission denied'), + errorType: 'permission_denied', + }, + } as CompletedToolCall; + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([errorToolCall]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('write file')); + const events = await collectEvents(session); + + const toolResp = events.find( + (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response', + ); + expect(toolResp?.isError).toBe(true); + // Uses error.message, not responseParts + expect(toolResp?.content).toEqual([ + { type: 'text', text: 'Permission denied' }, + ]); + expect(toolResp?.display?.result).toEqual({ + type: 'text', + text: 'Error display', + }); + }); + + it('stops on STOP_EXECUTION tool error', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'dangerous_tool'), + }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const stopToolCall: CompletedToolCall = { + status: CoreToolCallStatus.Error, + request: makeToolRequest('call-1', 'dangerous_tool'), + response: { + callId: 'call-1', + responseParts: [], + resultDisplay: undefined, + error: new Error('Stopped by policy'), + errorType: ToolErrorType.STOP_EXECUTION, + }, + } as CompletedToolCall; + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([stopToolCall]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('do something')); + const events = await collectEvents(session); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('completed'); + // Should NOT make a second call + expect(sendMock).toHaveBeenCalledTimes(1); + }); + + it('treats fatal tool errors as tool_response followed by agent_end failed', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'write_file'), + }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const fatalToolCall: CompletedToolCall = { + status: CoreToolCallStatus.Error, + request: makeToolRequest('call-1', 'write_file'), + response: { + callId: 'call-1', + responseParts: [], + resultDisplay: undefined, + error: new Error('Disk full'), + errorType: ToolErrorType.NO_SPACE_LEFT, + }, + } as CompletedToolCall; + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([fatalToolCall]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('write file')); + const events = await collectEvents(session); + + const toolResp = events.find( + (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response', + ); + expect(toolResp?.isError).toBe(true); + expect(toolResp?.content).toEqual([{ type: 'text', text: 'Disk full' }]); + expect( + events.some( + (e): e is AgentEvent<'error'> => + e.type === 'error' && e.fatal === true, + ), + ).toBe(false); + + const streamEnd = events.findLast( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('failed'); + expect(sendMock).toHaveBeenCalledTimes(1); + }); + + it('nudges the model if it returns an empty response after a tool execution', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + + // First turn: model requests a tool + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'read_file'), + }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + // Second turn: model goes silent (returns empty text response, no tool calls) + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + // Third turn (retry after nudge): model finally provides final answer + sendMock.mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'Analysis completed.' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([ + makeCompletedToolCall('call-1', 'read_file', 'file contents'), + ]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('read a file')); + const events = await collectEvents(session); + + const types = events.map((e) => e.type); + expect(types).toContain('tool_request'); + expect(types).toContain('tool_response'); + expect(types).toContain('agent_end'); + + // The nudged turn should have called sendMessageStream a third time! + expect(sendMock).toHaveBeenCalledTimes(3); + + // Verify that the third call received the correct system nudge message + expect(sendMock).toHaveBeenLastCalledWith( + [ + { + text: '[System: You successfully executed a tool but returned an empty response. Please analyze the tool output and explain your progress or final answer.]', + }, + ], + expect.any(AbortSignal), + expect.any(String), + undefined, + undefined, + ); + + const messages = events.filter( + (e): e is AgentEvent<'message'> => + e.type === 'message' && e.role === 'agent', + ); + expect( + messages.some( + (m) => + m.content[0] && + 'text' in m.content[0] && + m.content[0].text === 'Analysis completed.', + ), + ).toBe(true); + }); + }); + + describe('stream - terminal events', () => { + it('handles AgentExecutionStopped', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'hook', systemMessage: 'Halted by hook' }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('completed'); + expect(streamEnd?.data).toEqual({ message: 'Halted by hook' }); + }); + + it('handles AgentExecutionBlocked as non-terminal and continues the stream', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const blocked = events.find( + (e): e is AgentEvent<'error'> => + e.type === 'error' && e._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED', + ); + expect(blocked?.fatal).toBe(false); + expect(blocked?.message).toBe('Blocked by hook'); + + const messages = events.filter( + (e): e is AgentEvent<'message'> => + e.type === 'message' && e.role === 'agent', + ); + expect( + messages.some( + (message) => + message.content[0]?.type === 'text' && + message.content[0].text === 'Final answer', + ), + ).toBe(true); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('completed'); + }); + + it('handles Error events', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.Error, + value: { error: new Error('API error') }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const err = events.find( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(err?.message).toBe('API error'); + expect(events.some((e) => e.type === 'agent_end')).toBe(true); + }); + + it('handles LoopDetected as non-terminal warning event', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + // LoopDetected followed by more content β€” stream continues + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.LoopDetected }, + { type: GeminiEventType.Content, value: 'continuing after loop' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const warning = events.find( + (e): e is AgentEvent<'error'> => + e.type === 'error' && e._meta?.['code'] === 'LOOP_DETECTED', + ); + expect(warning).toBeDefined(); + expect(warning?.fatal).toBe(false); + + // Stream should have continued β€” content after loop detected + const messages = events.filter( + (e): e is AgentEvent<'message'> => + e.type === 'message' && e.role === 'agent', + ); + expect( + messages.some( + (m) => + m.content[0]?.type === 'text' && + m.content[0].text === 'continuing after loop', + ), + ).toBe(true); + + // Should still end with agent_end completed + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('completed'); + }); + }); + + describe('stream - max turns', () => { + it('emits agent_end with max_turns when the session turn limit is exceeded', async () => { + const configMock = deps.config.getMaxSessionTurns as ReturnType< + typeof vi.fn + >; + configMock.mockReturnValue(0); + + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.Content, value: 'should not be reached' }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('max_turns'); + expect(streamEnd?.data).toEqual({ + code: 'MAX_TURNS_EXCEEDED', + maxTurns: 0, + turnCount: 0, + }); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('treats GeminiClient MaxSessionTurns as a terminal max_turns stream end', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([{ type: GeminiEventType.MaxSessionTurns }]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const errorEvents = events.filter( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(errorEvents).toHaveLength(0); + + const streamEnd = events.findLast( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('max_turns'); + expect(streamEnd?.data).toEqual({ + code: 'MAX_TURNS_EXCEEDED', + }); + }); + }); + + describe('abort', () => { + it('treats abort before the first model event as aborted without fatal error', async () => { + let releaseAbort: (() => void) | undefined; + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + (async function* () { + await new Promise((resolve) => { + releaseAbort = resolve; + }); + yield* []; + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + throw abortError; + })(), + ); + + const session = new LegacyAgentSession(deps); + const { streamId } = await session.send(makeMessageSend('hi')); + await vi.advanceTimersByTimeAsync(0); + + await session.abort(); + releaseAbort?.(); + + const events = await collectEvents(session, { + streamId: streamId ?? undefined, + }); + expect( + events.some( + (event): event is AgentEvent<'error'> => + event.type === 'error' && event.fatal, + ), + ).toBe(false); + + const streamEnd = events.findLast( + (event): event is AgentEvent<'agent_end'> => event.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('aborted'); + }); + + it('aborts the stream', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + // Stream that yields content then checks abort signal via a deferred + let resolveHang: (() => void) | undefined; + sendMock.mockReturnValue( + (async function* () { + yield { + type: GeminiEventType.Content, + value: 'start', + } as ServerGeminiStreamEvent; + // Wait until externally resolved (by abort) + await new Promise((resolve) => { + resolveHang = resolve; + }); + yield { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + } as ServerGeminiStreamEvent; + })(), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + + // Give the loop time to start processing + await new Promise((r) => setTimeout(r, 50)); + + // Abort and resolve the hang so the generator can finish + await session.abort(); + resolveHang?.(); + + // Collect all events + const events = await collectEvents(session); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('aborted'); + }); + + it('treats abort during pending scheduler work as aborted without fatal error', async () => { + let resolveSchedule: ((value: CompletedToolCall[]) => void) | undefined; + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'slow_tool'), + }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockReturnValue( + new Promise((resolve) => { + resolveSchedule = resolve; + }), + ); + + const session = new LegacyAgentSession(deps); + const { streamId } = await session.send(makeMessageSend('hi')); + + await new Promise((resolve) => setTimeout(resolve, 25)); + await session.abort(); + resolveSchedule?.([makeCompletedToolCall('call-1', 'slow_tool', 'done')]); + + const events = await collectEvents(session, { + streamId: streamId ?? undefined, + }); + expect( + events.some( + (event): event is AgentEvent<'error'> => + event.type === 'error' && event.fatal, + ), + ).toBe(false); + expect(events.some((event) => event.type === 'tool_response')).toBe( + false, + ); + + const streamEnd = events.findLast( + (event): event is AgentEvent<'agent_end'> => event.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('aborted'); + }); + }); + + describe('events property', () => { + it('accumulates all events', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.Content, value: 'hi' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + await collectEvents(session); + + expect(session.events.length).toBeGreaterThan(0); + expect(session.events[0]?.type).toBe('message'); + }); + }); + + describe('subscription and stream scoping', () => { + it('subscribe receives live events for the next stream', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.Content, value: 'hello later' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const liveEvents: AgentEvent[] = []; + const unsubscribe = session.subscribe((event) => { + liveEvents.push(event); + }); + + const { streamId } = await session.send(makeMessageSend('hi')); + await collectEvents(session, { streamId: streamId ?? undefined }); + unsubscribe(); + + expect(liveEvents.length).toBeGreaterThan(0); + expect(liveEvents[0]?.type).toBe('message'); + expect(liveEvents.every((event) => event.streamId === streamId)).toBe( + true, + ); + }); + + it('subscribe is live-only and does not replay old history when idle', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'first answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ) + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'second answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const first = await session.send(makeMessageSend('first request')); + await collectEvents(session, { streamId: first.streamId ?? undefined }); + + const liveEvents: AgentEvent[] = []; + const unsubscribe = session.subscribe((event) => { + liveEvents.push(event); + }); + + const second = await session.send(makeMessageSend('second request')); + await collectEvents(session, { streamId: second.streamId ?? undefined }); + unsubscribe(); + + expect(liveEvents.length).toBeGreaterThan(0); + expect( + liveEvents.every((event) => event.streamId === second.streamId), + ).toBe(true); + expect( + liveEvents.some( + (event) => + event.type === 'message' && + event.role === 'user' && + event.content[0]?.type === 'text' && + event.content[0].text === 'first request', + ), + ).toBe(false); + }); + + it('streams only the requested streamId', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'first answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ) + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'second answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const first = await session.send(makeMessageSend('first request')); + await collectEvents(session, { streamId: first.streamId ?? undefined }); + + const second = await session.send(makeMessageSend('second request')); + await collectEvents(session, { streamId: second.streamId ?? undefined }); + + const firstStreamEvents = await collectEvents(session, { + streamId: first.streamId ?? undefined, + }); + + expect( + firstStreamEvents.every((event) => event.streamId === first.streamId), + ).toBe(true); + expect( + firstStreamEvents.some( + (e) => + e.type === 'message' && + e.role === 'agent' && + e.content[0]?.type === 'text' && + e.content[0].text === 'first answer', + ), + ).toBe(true); + expect( + firstStreamEvents.some( + (e) => + e.type === 'message' && + e.role === 'agent' && + e.content[0]?.type === 'text' && + e.content[0].text === 'second answer', + ), + ).toBe(false); + }); + + it('resumes from eventId within the same stream only', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'first answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ) + .mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'second answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + const first = await session.send(makeMessageSend('first request')); + await collectEvents(session, { streamId: first.streamId ?? undefined }); + + await session.send(makeMessageSend('second request')); + await collectEvents(session); + + const firstAgentMessage = session.events.find( + (e): e is AgentEvent<'message'> => + e.type === 'message' && + e.role === 'agent' && + e.streamId === first.streamId && + e.content[0]?.type === 'text' && + e.content[0].text === 'first answer', + ); + expect(firstAgentMessage).toBeDefined(); + + const resumedEvents = await collectEvents(session, { + eventId: firstAgentMessage?.id, + }); + expect( + resumedEvents.every((event) => event.streamId === first.streamId), + ).toBe(true); + expect(resumedEvents.map((event) => event.type)).toEqual(['agent_end']); + expect( + resumedEvents.some( + (e) => + e.type === 'message' && + e.role === 'agent' && + e.content[0]?.type === 'text' && + e.content[0].text === 'second answer', + ), + ).toBe(false); + }); + }); + + describe('agent_end ordering', () => { + it('agent_end is always the final event yielded', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { type: GeminiEventType.Content, value: 'Hello' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + expect(events.length).toBeGreaterThan(0); + expect(events[events.length - 1]?.type).toBe('agent_end'); + }); + + it('agent_end is final even after error events', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValue( + makeStream([ + { + type: GeminiEventType.Error, + value: { error: new Error('API error') }, + }, + ]), + ); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + expect(events[events.length - 1]?.type).toBe('agent_end'); + }); + }); + + describe('intermediate Finished events', () => { + it('does NOT emit agent_end when tool calls are pending', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + // First turn: tool request + Finished (should NOT produce agent_end) + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'read_file'), + }, + { + type: GeminiEventType.Finished, + value: { + reason: FinishReason.STOP, + usageMetadata: { + promptTokenCount: 50, + candidatesTokenCount: 20, + }, + }, + }, + ]), + ); + // Second turn: final answer + sendMock.mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'Answer' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([ + makeCompletedToolCall('call-1', 'read_file', 'data'), + ]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('do it')); + const events = await collectEvents(session); + + // Only one agent_end at the very end + const streamEnds = events.filter((e) => e.type === 'agent_end'); + expect(streamEnds).toHaveLength(1); + expect(streamEnds[0]).toBe(events[events.length - 1]); + }); + + it('emits usage for intermediate Finished events', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockReturnValueOnce( + makeStream([ + { + type: GeminiEventType.ToolCallRequest, + value: makeToolRequest('call-1', 'read_file'), + }, + { + type: GeminiEventType.Finished, + value: { + reason: FinishReason.STOP, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 30, + }, + }, + }, + ]), + ); + sendMock.mockReturnValueOnce( + makeStream([ + { type: GeminiEventType.Content, value: 'Done' }, + { + type: GeminiEventType.Finished, + value: { reason: FinishReason.STOP, usageMetadata: undefined }, + }, + ]), + ); + + const scheduleMock = deps.scheduler.schedule as ReturnType; + scheduleMock.mockResolvedValueOnce([ + makeCompletedToolCall('call-1', 'read_file', 'contents'), + ]); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('go')); + const events = await collectEvents(session); + + // Should have at least one usage event from the intermediate Finished + const usageEvents = events.filter( + (e): e is AgentEvent<'usage'> => e.type === 'usage', + ); + expect(usageEvents.length).toBeGreaterThanOrEqual(1); + expect(usageEvents[0]?.inputTokens).toBe(100); + expect(usageEvents[0]?.outputTokens).toBe(30); + }); + }); + + describe('error handling in runLoop', () => { + it('catches thrown errors and emits error + agent_end', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + sendMock.mockImplementation(() => { + throw new Error('Connection refused'); + }); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const err = events.find( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(err?.message).toBe('Connection refused'); + expect(err?.fatal).toBe(true); + expect(err?._meta?.['stack']).toBeDefined(); + + const streamEnd = events.find( + (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', + ); + expect(streamEnd?.reason).toBe('failed'); + }); + }); + + describe('_emitErrorAndAgentEnd metadata', () => { + it('preserves exitCode and code in _meta for FatalError', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + // Simulate a FatalError being thrown + const { FatalError } = await import('../utils/errors.js'); + sendMock.mockImplementation(() => { + throw new FatalError('Disk full', 44); + }); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const err = events.find( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(err?.message).toBe('Disk full'); + expect(err?.fatal).toBe(true); + expect(err?._meta?.['exitCode']).toBe(44); + expect(err?._meta?.['errorName']).toBe('FatalError'); + }); + + it('preserves exitCode for non-FatalError errors that carry one', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + const exitCodeError = new Error('custom exit'); + (exitCodeError as Error & { exitCode: number }).exitCode = 17; + sendMock.mockImplementation(() => { + throw exitCodeError; + }); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const err = events.find( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(err?._meta?.['exitCode']).toBe(17); + }); + + it('preserves code in _meta for errors with code property', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + const codedError = new Error('ENOENT'); + (codedError as Error & { code: string }).code = 'ENOENT'; + sendMock.mockImplementation(() => { + throw codedError; + }); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const err = events.find( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(err?._meta?.['code']).toBe('ENOENT'); + }); + + it('preserves status in _meta for errors with status property', async () => { + const sendMock = deps.client.sendMessageStream as ReturnType< + typeof vi.fn + >; + const statusError = new Error('rate limited'); + (statusError as Error & { status: string }).status = 'RESOURCE_EXHAUSTED'; + sendMock.mockImplementation(() => { + throw statusError; + }); + + const session = new LegacyAgentSession(deps); + await session.send(makeMessageSend('hi')); + const events = await collectEvents(session); + + const err = events.find( + (e): e is AgentEvent<'error'> => e.type === 'error', + ); + expect(err?._meta?.['status']).toBe('RESOURCE_EXHAUSTED'); + }); + }); +}); diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..f19fe603d67d2d72d57cee60fd2b0b0c1b9aae26 --- /dev/null +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -0,0 +1,519 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview LegacyAgentSession backed by the existing Gemini client + + * scheduler loop, adapted to the merged AgentProtocol / AgentSession surface. + */ + +import { GeminiEventType } from '../core/turn.js'; +import type { Part, FinishReason } from '@google/genai'; +import type { GeminiClient } from '../core/client.js'; +import type { Config } from '../config/config.js'; +import type { ToolCallRequestInfo } from '../scheduler/types.js'; +import { Scheduler } from '../scheduler/scheduler.js'; +import { recordToolCallInteractions } from '../code_assist/telemetry.js'; +import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import type { EditorType } from '../utils/editor.js'; +import { + buildToolResponseData, + contentPartsToGeminiParts, + geminiPartsToContentParts, +} from './content-utils.js'; +import { populateToolDisplay } from './tool-display-utils.js'; +import { AgentSession } from './agent-session.js'; +import { + createTranslationState, + mapFinishReason, + translateEvent, + type TranslationState, +} from './event-translator.js'; +import type { + AgentEvent, + AgentProtocol, + AgentSend, + ContentPart, + StreamEndReason, + Unsubscribe, +} from './types.js'; + +function isAbortLikeError(err: unknown): boolean { + return err instanceof Error && err.name === 'AbortError'; +} + +export interface LegacyAgentSessionDeps { + config: Config; + client?: GeminiClient; + scheduler?: Scheduler; + promptId?: string; + streamId?: string; + getPreferredEditor?: () => EditorType | undefined; +} + +const schedulerMap = new WeakMap(); + +export class LegacyAgentProtocol implements AgentProtocol { + private _events: AgentEvent[] = []; + private _subscribers = new Set<(event: AgentEvent) => void>(); + private _translationState: TranslationState; + private _agentEndEmitted = false; + private _activeStreamId?: string; + private _abortController = new AbortController(); + private _nextStreamIdOverride?: string; + + private readonly _client: GeminiClient; + private readonly _scheduler: Scheduler; + private readonly _config: Config; + private readonly _promptId: string; + + constructor(deps: LegacyAgentSessionDeps) { + this._translationState = createTranslationState(deps.streamId); + this._nextStreamIdOverride = deps.streamId; + this._config = deps.config; + this._client = deps.client ?? deps.config.getGeminiClient(); + this._promptId = deps.promptId ?? deps.config.promptId ?? ''; + if (deps.scheduler) { + this._scheduler = deps.scheduler; + } else { + let scheduler = schedulerMap.get(deps.config); + if (!scheduler) { + const sessionId = deps.config.getSessionId(); + const schedulerId = `legacy-agent-scheduler-${sessionId}`; + scheduler = new Scheduler({ + context: deps.config, + schedulerId, + getPreferredEditor: deps.getPreferredEditor ?? (() => undefined), + }); + schedulerMap.set(deps.config, scheduler); + } + this._scheduler = scheduler; + } + } + + get events(): readonly AgentEvent[] { + return this._events; + } + + subscribe(callback: (event: AgentEvent) => void): Unsubscribe { + this._subscribers.add(callback); + return () => { + this._subscribers.delete(callback); + }; + } + + async send(payload: AgentSend): Promise<{ streamId: string }> { + const message = 'message' in payload ? payload.message : undefined; + if (!message) { + throw new Error( + 'LegacyAgentSession.send() only supports message sends for the moment.', + ); + } + + if (this._activeStreamId) { + // TODO: Interactive may eventually allow selected in-stream sends such as + // updates or elicitation responses. Keep rejecting all concurrent sends + // here until we define those correlation semantics. + throw new Error( + 'LegacyAgentSession.send() cannot be called while a stream is active.', + ); + } + + this._beginNewStream(); + const streamId = this._translationState.streamId; + const parts = contentPartsToGeminiParts(message.content); + const userMessage = this._makeUserMessageEvent( + message.content, + message.displayContent, + payload._meta, + ); + + this._emit([userMessage]); + + this._scheduleRunLoop(parts, message.displayContent); + + return { streamId }; + } + + async abort(): Promise { + this._abortController.abort(); + } + + private _scheduleRunLoop( + initialParts: Part[], + displayContent?: string, + ): void { + // Use a macrotask so send() resolves with the streamId before agent_start + // is emitted and consumers can attach to the stream without racing startup. + setTimeout(() => { + void this._runLoopInBackground(initialParts, displayContent); + }, 0); + } + + private async _runLoopInBackground( + initialParts: Part[], + displayContent?: string, + ): Promise { + this._ensureAgentStart(); + try { + await this._runLoop(initialParts, displayContent); + } catch (err: unknown) { + if (this._abortController.signal.aborted || isAbortLikeError(err)) { + this._ensureAgentEnd('aborted'); + } else { + this._emitErrorAndAgentEnd(err); + } + } finally { + this._clearActiveStream(); + } + } + + private async _runLoop( + initialParts: Part[], + initialDisplayContent?: string, + ): Promise { + let currentParts: Part[] = initialParts; + let currentDisplayContent = initialDisplayContent; + let turnCount = 0; + const maxTurns = this._config.getMaxSessionTurns(); + let isAfterToolResponse = false; + + while (true) { + turnCount++; + if (maxTurns >= 0 && turnCount > maxTurns) { + this._finishStream('max_turns', { + code: 'MAX_TURNS_EXCEEDED', + maxTurns, + turnCount: turnCount - 1, + }); + return; + } + + const toolCallRequests: ToolCallRequestInfo[] = []; + let finishedReason: FinishReason | undefined = undefined; + let hasVisibleText = false; + + const responseStream = this._client.sendMessageStream( + currentParts, + this._abortController.signal, + this._promptId, + undefined, + currentDisplayContent, + ); + currentDisplayContent = undefined; + + for await (const event of responseStream) { + if (this._abortController.signal.aborted) { + this._finishStream('aborted'); + return; + } + + if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + } + + if (event.type === GeminiEventType.Content) { + if (typeof event.value === 'string' && event.value.trim() !== '') { + hasVisibleText = true; + } + } + + this._emit(translateEvent(event, this._translationState)); + + switch (event.type) { + case GeminiEventType.Error: + case GeminiEventType.InvalidStream: + case GeminiEventType.ContextWindowWillOverflow: + this._finishStream('failed'); + return; + case GeminiEventType.Finished: + finishedReason = event.value.reason; + break; + case GeminiEventType.AgentExecutionStopped: + case GeminiEventType.UserCancelled: + case GeminiEventType.MaxSessionTurns: + this._clearActiveStream(); + return; + default: + break; + } + } + + if (this._abortController.signal.aborted) { + this._finishStream('aborted'); + return; + } + + if (toolCallRequests.length === 0) { + if (isAfterToolResponse && !hasVisibleText) { + const nudgeMessage = + '[System: You successfully executed a tool but returned an empty response. Please analyze the tool output and explain your progress or final answer.]'; + + currentParts = [{ text: nudgeMessage }]; + isAfterToolResponse = false; + continue; + } + + if (finishedReason !== undefined) { + this._finishStream(mapFinishReason(finishedReason)); + } else { + this._finishStream('completed'); + } + return; + } + + const completedToolCalls = await this._scheduler.schedule( + toolCallRequests, + this._abortController.signal, + ); + + if (this._abortController.signal.aborted) { + this._finishStream('aborted'); + return; + } + + const toolResponseParts: Part[] = []; + for (const tc of completedToolCalls) { + const response = tc.response; + const request = tc.request; + const content: ContentPart[] = response.error + ? [{ type: 'text', text: response.error.message }] + : geminiPartsToContentParts(response.responseParts); + const display = populateToolDisplay({ + name: request.name, + invocation: 'invocation' in tc ? tc.invocation : undefined, + resultDisplay: response.resultDisplay, + displayName: 'tool' in tc ? tc.tool?.displayName : undefined, + display: response.display, + }); + const data = buildToolResponseData(response); + + this._emit([ + this._makeToolResponseEvent({ + requestId: request.callId, + name: request.name, + content, + isError: response.error !== undefined, + ...(display ? { display } : {}), + ...(data ? { data } : {}), + }), + ]); + + if (response.responseParts) { + toolResponseParts.push(...response.responseParts); + } + } + + try { + const currentModel = + this._client.getCurrentSequenceModel() ?? this._config.getModel(); + this._client + .getChat() + .recordCompletedToolCalls(currentModel, completedToolCalls); + await recordToolCallInteractions(this._config, completedToolCalls); + } catch (error) { + debugLogger.error( + `Error recording completed tool call information: ${error}`, + ); + } + + const stopTool = completedToolCalls.find( + (tc) => + tc.response.errorType === ToolErrorType.STOP_EXECUTION && + tc.response.error !== undefined, + ); + if (stopTool) { + this._finishStream('completed'); + return; + } + + const fatalTool = completedToolCalls.find((tc) => + isFatalToolError(tc.response.errorType), + ); + if (fatalTool) { + this._finishStream('failed'); + return; + } + + currentParts = toolResponseParts; + isAfterToolResponse = completedToolCalls.length > 0; + } + } + + private _emit(events: AgentEvent[]): void { + if (events.length === 0) { + return; + } + + const subscribers = [...this._subscribers]; + for (const event of events) { + if (!this._events.some((existing) => existing.id === event.id)) { + this._events.push(event); + } + if (event.type === 'agent_end') { + this._agentEndEmitted = true; + } + for (const subscriber of subscribers) { + subscriber(event); + } + } + } + + private _clearActiveStream(): void { + this._activeStreamId = undefined; + } + + private _beginNewStream(): void { + this._translationState = createTranslationState(this._nextStreamIdOverride); + this._nextStreamIdOverride = undefined; + this._abortController = new AbortController(); + this._agentEndEmitted = false; + this._activeStreamId = this._translationState.streamId; + } + + private _ensureAgentStart(): void { + if (!this._translationState.streamStartEmitted) { + this._translationState.streamStartEmitted = true; + this._emit([this._makeAgentStartEvent()]); + } + } + + private _ensureAgentEnd(reason: StreamEndReason = 'completed'): void { + if (!this._agentEndEmitted && this._translationState.streamStartEmitted) { + this._agentEndEmitted = true; + this._emit([this._makeAgentEndEvent(reason)]); + } + } + + private _finishStream( + reason: StreamEndReason, + data?: Record, + ): void { + if (data && !this._agentEndEmitted) { + this._emit([this._makeAgentEndEvent(reason, data)]); + } else { + this._ensureAgentEnd(reason); + } + this._clearActiveStream(); + } + + /** + * Preserve error identity fields in _meta so downstream consumers can + * reconstruct fatal CLI errors. + */ + private _emitErrorAndAgentEnd(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + + this._ensureAgentStart(); + + const meta: Record = {}; + if (err instanceof Error) { + meta['errorName'] = err.constructor.name; + meta['stack'] = err.stack; + if ('exitCode' in err && typeof err.exitCode === 'number') { + meta['exitCode'] = err.exitCode; + } + if ('code' in err) { + meta['code'] = err.code; + } + if ('status' in err) { + meta['status'] = err.status; + } + } + + this._emit([ + this._makeErrorEvent({ + status: 'INTERNAL', + message, + fatal: true, + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), + }), + ]); + + this._ensureAgentEnd('failed'); + } + + private _nextEventFields() { + return { + id: `${this._translationState.streamId}-${this._translationState.eventCounter++}`, + timestamp: new Date().toISOString(), + streamId: this._translationState.streamId, + }; + } + + private _makeUserMessageEvent( + content: ContentPart[], + displayContent?: string, + meta?: Record, + ): AgentEvent<'message'> { + const eventContent: ContentPart[] = displayContent + ? [{ type: 'text', text: displayContent }] + : content; + const event = { + ...this._nextEventFields(), + type: 'message', + role: 'user', + content: eventContent, + ...(meta ? { _meta: meta } : {}), + } satisfies AgentEvent<'message'>; + return event; + } + + private _makeToolResponseEvent( + payload: Omit< + AgentEvent<'tool_response'>, + 'id' | 'timestamp' | 'streamId' | 'type' + >, + ): AgentEvent<'tool_response'> { + const event = { + ...this._nextEventFields(), + type: 'tool_response', + ...payload, + } satisfies AgentEvent<'tool_response'>; + return event; + } + + private _makeAgentStartEvent(): AgentEvent<'agent_start'> { + const event = { + ...this._nextEventFields(), + type: 'agent_start', + } satisfies AgentEvent<'agent_start'>; + return event; + } + + private _makeAgentEndEvent( + reason: StreamEndReason, + data?: Record, + ): AgentEvent<'agent_end'> { + const event = { + ...this._nextEventFields(), + type: 'agent_end', + reason, + ...(data ? { data } : {}), + } satisfies AgentEvent<'agent_end'>; + return event; + } + + private _makeErrorEvent( + payload: Omit< + AgentEvent<'error'>, + 'id' | 'timestamp' | 'streamId' | 'type' + >, + ): AgentEvent<'error'> { + const event = { + ...this._nextEventFields(), + type: 'error', + ...payload, + } satisfies AgentEvent<'error'>; + return event; + } +} + +export class LegacyAgentSession extends AgentSession { + constructor(deps: LegacyAgentSessionDeps) { + super(new LegacyAgentProtocol(deps)); + } +} diff --git a/packages/core/src/agent/mock.test.ts b/packages/core/src/agent/mock.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..64403008a66784a1a4f88ccf23124db4453b8dfb --- /dev/null +++ b/packages/core/src/agent/mock.test.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { MockAgentProtocol } from './mock.js'; +import type { AgentEvent, AgentProtocol } from './types.js'; + +const waitForStreamEnd = (session: AgentProtocol): Promise => + new Promise((resolve) => { + const events: AgentEvent[] = []; + const unsubscribe = session.subscribe((e) => { + events.push(e); + if (e.type === 'agent_end') { + unsubscribe(); + resolve(events); + } + }); + }); + +describe('MockAgentProtocol', () => { + it('should emit queued events on send and subscribe', async () => { + const session = new MockAgentProtocol(); + const event1 = { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + } as AgentEvent; + + session.pushResponse([event1]); + + const streamPromise = waitForStreamEnd(session); + + const { streamId } = await session.send({ + message: { content: [{ type: 'text', text: 'hi' }] }, + }); + expect(streamId).toBeDefined(); + + const streamedEvents = await streamPromise; + + // Ordered: user message, agent_start, agent message, agent_end = 4 events + expect(streamedEvents).toHaveLength(4); + expect(streamedEvents[0].type).toBe('message'); + expect((streamedEvents[0] as AgentEvent<'message'>).role).toBe('user'); + expect(streamedEvents[1].type).toBe('agent_start'); + expect(streamedEvents[2].type).toBe('message'); + expect((streamedEvents[2] as AgentEvent<'message'>).role).toBe('agent'); + expect(streamedEvents[3].type).toBe('agent_end'); + + expect(session.events).toHaveLength(4); + expect(session.events).toEqual(streamedEvents); + }); + + it('should handle multiple responses', async () => { + const session = new MockAgentProtocol(); + + // Test with empty payload (no message injected) + session.pushResponse([]); + session.pushResponse([ + { + type: 'error', + message: 'fail', + fatal: true, + status: 'RESOURCE_EXHAUSTED', + }, + ]); + + // First send + const stream1Promise = waitForStreamEnd(session); + const { streamId: s1 } = await session.send({ + update: { title: 't1' }, + }); + const events1 = await stream1Promise; + expect(events1).toHaveLength(3); // session_update, agent_start, agent_end + expect(events1[0].type).toBe('session_update'); + expect(events1[1].type).toBe('agent_start'); + expect(events1[2].type).toBe('agent_end'); + + // Second send + const stream2Promise = waitForStreamEnd(session); + const { streamId: s2 } = await session.send({ + update: { title: 't2' }, + }); + expect(s1).not.toBe(s2); + const events2 = await stream2Promise; + expect(events2).toHaveLength(4); // session_update, agent_start, error, agent_end + expect(events2[0].type).toBe('session_update'); + expect(events2[1].type).toBe('agent_start'); + expect(events2[2].type).toBe('error'); + expect(events2[3].type).toBe('agent_end'); + + expect(session.events).toHaveLength(7); + }); + + it('should handle abort on a waiting stream', async () => { + const session = new MockAgentProtocol(); + // Use keepOpen to prevent auto agent_end + session.pushResponse([{ type: 'message' }], { keepOpen: true }); + + const events: AgentEvent[] = []; + let resolveStream: (evs: AgentEvent[]) => void; + const streamPromise = new Promise((res) => { + resolveStream = res; + }); + + session.subscribe((e) => { + events.push(e); + if (e.type === 'agent_end') { + resolveStream(events); + } + }); + + const { streamId: _streamId } = await session.send({ + update: { title: 't' }, + }); + + // Initial events should have been emitted + expect(events.map((e) => e.type)).toEqual([ + 'session_update', + 'agent_start', + 'message', + ]); + + // At this point, the stream should be "waiting" for more events because it's still active + // and hasn't seen an agent_end. + await session.abort(); + + const finalEvents = await streamPromise; + expect(finalEvents[3].type).toBe('agent_end'); + expect((finalEvents[3] as AgentEvent<'agent_end'>).reason).toBe('aborted'); + }); + + it('should handle pushToStream on a waiting stream', async () => { + const session = new MockAgentProtocol(); + session.pushResponse([], { keepOpen: true }); + + const events: AgentEvent[] = []; + session.subscribe((e) => events.push(e)); + + const { streamId } = await session.send({ update: { title: 't' } }); + + expect(events.map((e) => e.type)).toEqual([ + 'session_update', + 'agent_start', + ]); + + // Push new event to active stream + session.pushToStream(streamId!, [{ type: 'message' }]); + + expect(events).toHaveLength(3); + expect(events[2].type).toBe('message'); + + await session.abort(); + expect(events).toHaveLength(4); + expect(events[3].type).toBe('agent_end'); + }); + + it('should handle pushToStream with close option', async () => { + const session = new MockAgentProtocol(); + session.pushResponse([], { keepOpen: true }); + + const streamPromise = waitForStreamEnd(session); + const { streamId } = await session.send({ update: { title: 't' } }); + + // Push new event and close + session.pushToStream(streamId!, [{ type: 'message' }], { close: true }); + + const events = await streamPromise; + expect(events.map((e) => e.type)).toEqual([ + 'session_update', + 'agent_start', + 'message', + 'agent_end', + ]); + expect((events[3] as AgentEvent<'agent_end'>).reason).toBe('completed'); + }); + + it('should not double up on agent_end if provided manually', async () => { + const session = new MockAgentProtocol(); + session.pushResponse([ + { type: 'message' }, + { type: 'agent_end', reason: 'completed' }, + ]); + + const streamPromise = waitForStreamEnd(session); + await session.send({ update: { title: 't' } }); + + const events = await streamPromise; + const endEvents = events.filter((e) => e.type === 'agent_end'); + expect(endEvents).toHaveLength(1); + }); + + it('should handle elicitations', async () => { + const session = new MockAgentProtocol(); + session.pushResponse([]); + + const streamPromise = waitForStreamEnd(session); + await session.send({ + elicitations: [ + { requestId: 'r1', action: 'accept', content: { foo: 'bar' } }, + ], + }); + + const events = await streamPromise; + expect(events[0].type).toBe('elicitation_response'); + expect((events[0] as AgentEvent<'elicitation_response'>).requestId).toBe( + 'r1', + ); + expect(events[1].type).toBe('agent_start'); + }); + + it('should handle updates and track state', async () => { + const session = new MockAgentProtocol(); + session.pushResponse([]); + + const streamPromise = waitForStreamEnd(session); + await session.send({ + update: { title: 'New Title', model: 'gpt-4', config: { x: 1 } }, + }); + + expect(session.title).toBe('New Title'); + expect(session.model).toBe('gpt-4'); + expect(session.config).toEqual({ x: 1 }); + + const events = await streamPromise; + expect(events[0].type).toBe('session_update'); + expect(events[1].type).toBe('agent_start'); + }); + + it('should return streamId: null if no response queued', async () => { + const session = new MockAgentProtocol(); + const { streamId } = await session.send({ update: { title: 'foo' } }); + expect(streamId).toBeNull(); + expect(session.events).toHaveLength(1); + expect(session.events[0].type).toBe('session_update'); + expect(session.events[0].streamId).toEqual(expect.any(String)); + }); + + it('should throw on action', async () => { + const session = new MockAgentProtocol(); + await expect( + session.send({ action: { type: 'foo', data: {} } }), + ).rejects.toThrow('Actions not supported in MockAgentProtocol: foo'); + }); +}); diff --git a/packages/core/src/agent/mock.ts b/packages/core/src/agent/mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..26ef965d6d10a774f83099d7c2a5919995307a29 --- /dev/null +++ b/packages/core/src/agent/mock.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AgentEvent, + AgentEventCommon, + AgentEventData, + AgentProtocol, + AgentSend, + ContentPart, + Unsubscribe, +} from './types.js'; + +export type MockAgentEvent = Partial & AgentEventData; + +export interface PushResponseOptions { + /** If true, does not automatically add an agent_end event. */ + keepOpen?: boolean; +} + +/** + * A mock implementation of AgentProtocol for testing. + * Allows queuing responses that will be yielded when send() is called. + */ +export class MockAgentProtocol implements AgentProtocol { + private _events: AgentEvent[] = []; + private _responses: Array<{ + events: MockAgentEvent[]; + options?: PushResponseOptions; + }> = []; + private _subscribers = new Set<(event: AgentEvent) => void>(); + private _activeStreamIds = new Set(); + private _lastStreamId?: string | null; + private _nextEventId = 1; + private _nextStreamId = 1; + + title?: string; + model?: string; + config?: Record; + + constructor(initialEvents: AgentEvent[] = []) { + this._events = [...initialEvents]; + } + + /** + * All events that have occurred in this session so far. + */ + get events(): AgentEvent[] { + return this._events; + } + + subscribe(callback: (event: AgentEvent) => void): Unsubscribe { + this._subscribers.add(callback); + return () => this._subscribers.delete(callback); + } + + private _emit(event: AgentEvent) { + if (!this._events.some((e) => e.id === event.id)) { + this._events.push(event); + } + for (const callback of this._subscribers) { + callback(event); + } + if (event.type === 'agent_end' && event.streamId) { + this._activeStreamIds.delete(event.streamId); + } + } + + /** + * Queues a sequence of events to be "emitted" by the agent in response to the + * next send() call. + */ + pushResponse(events: MockAgentEvent[], options?: PushResponseOptions) { + this._responses.push({ events, options }); + } + + /** + * Appends events to an existing stream and notifies any waiting listeners. + */ + pushToStream( + streamId: string, + events: MockAgentEvent[], + options?: { close?: boolean }, + ) { + const now = new Date().toISOString(); + for (const eventData of events) { + const event = this._normalizeEvent(eventData, now, streamId); + this._emit(event); + } + + if ( + options?.close && + !events.some((eventData) => eventData.type === 'agent_end') + ) { + this._emit( + this._normalizeEvent( + { type: 'agent_end', reason: 'completed' }, + now, + streamId, + ), + ); + } + } + + async send(payload: AgentSend): Promise<{ streamId: string | null }> { + const responseData = this._responses.shift(); + const { events: response, options } = responseData ?? { + events: [], + }; + + // If there were queued responses (even if empty array), we trigger a stream. + const hasResponseEvents = responseData !== undefined; + const streamId = hasResponseEvents + ? (response[0]?.streamId ?? `mock-stream-${this._nextStreamId++}`) + : null; + + const now = new Date().toISOString(); + const eventsToEmit: AgentEvent[] = []; + let fallbackStreamId: string | undefined; + + // All emitted events stay correlated to a stream even if this send does not + // start agent activity and therefore returns `streamId: null`. + const normalize = (eventData: MockAgentEvent): AgentEvent => + this._normalizeEvent( + eventData, + now, + eventData.streamId ?? + streamId ?? + (fallbackStreamId ??= `mock-stream-${this._nextStreamId++}`), + ); + + // 1. User/Update event (BEFORE agent_start) + if ('message' in payload && payload.message) { + const message = Array.isArray(payload.message) + ? { content: payload.message, displayContent: undefined } + : payload.message; + const userContent: ContentPart[] = message.displayContent + ? [{ type: 'text', text: message.displayContent }] + : message.content; + eventsToEmit.push( + normalize({ + type: 'message', + role: 'user', + content: userContent, + _meta: payload._meta, + }), + ); + } else if ('elicitations' in payload && payload.elicitations) { + payload.elicitations.forEach((elicitation) => { + eventsToEmit.push( + normalize({ + type: 'elicitation_response', + ...elicitation, + _meta: payload._meta, + }), + ); + }); + } else if ( + 'update' in payload && + payload.update && + Object.keys(payload.update).length > 0 + ) { + if (payload.update.title) this.title = payload.update.title; + if (payload.update.model) this.model = payload.update.model; + if (payload.update.config) { + this.config = payload.update.config; + } + eventsToEmit.push( + normalize({ + type: 'session_update', + ...payload.update, + _meta: payload._meta, + }), + ); + } else if ('action' in payload && payload.action) { + throw new Error( + `Actions not supported in MockAgentProtocol: ${payload.action.type}`, + ); + } + + // 2. agent_start (if stream) + if (streamId) { + if (!response.some((eventData) => eventData.type === 'agent_start')) { + eventsToEmit.push( + normalize({ + type: 'agent_start', + streamId, + }), + ); + } + } + + // 3. Response events + for (const eventData of response) { + eventsToEmit.push(normalize(eventData)); + } + + // 4. agent_end (if stream and not manual) + if (streamId && !options?.keepOpen) { + if (!eventsToEmit.some((e) => e.type === 'agent_end')) { + eventsToEmit.push( + normalize({ + type: 'agent_end', + reason: 'completed', + streamId, + }), + ); + } + } + + if (streamId) { + this._activeStreamIds.add(streamId); + } + this._lastStreamId = streamId; + + // Emit events asynchronously so the caller receives the streamId first. + if (eventsToEmit.length > 0) { + void Promise.resolve().then(() => { + for (const event of eventsToEmit) { + this._emit(event); + } + }); + } + + return { streamId }; + } + + private _normalizeEvent( + eventData: MockAgentEvent, + timestamp: string, + streamId: string, + ): AgentEvent { + // TypeScript loses the specific union member when we add common event + // fields here, so keep the narrowing local to this mock-only helper. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + ...eventData, + id: eventData.id ?? `e-${this._nextEventId++}`, + timestamp: eventData.timestamp ?? timestamp, + streamId: eventData.streamId ?? streamId, + } as AgentEvent; + } + + async abort(): Promise { + if (this._lastStreamId && this._activeStreamIds.has(this._lastStreamId)) { + const streamId = this._lastStreamId; + this._emit( + this._normalizeEvent( + { type: 'agent_end', reason: 'aborted' }, + new Date().toISOString(), + streamId, + ), + ); + } + } +} diff --git a/packages/core/src/agent/tool-display-utils.test.ts b/packages/core/src/agent/tool-display-utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac583c000e67b44bc6bd9ad2999beeeb81e90149 --- /dev/null +++ b/packages/core/src/agent/tool-display-utils.test.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + ToolInvocation, + ToolResult, + ToolResultDisplay, +} from '../tools/tools.js'; +import type { DisplayContent } from './types.js'; +import { + populateToolDisplay, + renderDisplayDiff, + displayContentToString, +} from './tool-display-utils.js'; + +describe('tool-display-utils', () => { + describe('populateToolDisplay', () => { + it('uses displayName if provided', () => { + const mockInvocation = { + getDescription: () => 'Doing something...', + } as unknown as ToolInvocation; + + const display = populateToolDisplay({ + name: 'raw-name', + invocation: mockInvocation, + displayName: 'Custom Display Name', + }); + expect(display.name).toBe('Custom Display Name'); + expect(display.description).toBe('Doing something...'); + }); + + it('falls back to raw name if no displayName provided', () => { + const mockInvocation = { + getDescription: () => 'Doing something...', + } as unknown as ToolInvocation; + + const display = populateToolDisplay({ + name: 'raw-name', + invocation: mockInvocation, + }); + expect(display.name).toBe('raw-name'); + }); + + it('populates result from resultDisplay', () => { + const display = populateToolDisplay({ + name: 'test', + resultDisplay: 'hello world', + }); + expect(display.result).toEqual({ type: 'text', text: 'hello world' }); + }); + + it('translates FileDiff to DisplayDiff', () => { + const fileDiff = { + fileDiff: '@@ ...', + fileName: 'test.ts', + filePath: 'src/test.ts', + originalContent: 'old', + newContent: 'new', + } as unknown as ToolResultDisplay; + const display = populateToolDisplay({ + name: 'test', + resultDisplay: fileDiff, + }); + expect(display.result).toEqual({ + type: 'diff', + path: 'src/test.ts', + beforeText: 'old', + afterText: 'new', + }); + }); + }); + + describe('renderDisplayDiff', () => { + it('renders a universal diff', () => { + const diff = { + type: 'diff' as const, + path: 'test.ts', + beforeText: 'line 1\nline 2', + afterText: 'line 1\nline 2 modified', + }; + const rendered = renderDisplayDiff(diff); + expect(rendered).toContain('--- test.ts\tOriginal'); + expect(rendered).toContain('+++ test.ts\tModified'); + expect(rendered).toContain('-line 2'); + expect(rendered).toContain('+line 2 modified'); + }); + }); + + describe('displayContentToString', () => { + it('returns undefined for undefined input', () => { + expect(displayContentToString(undefined)).toBeUndefined(); + }); + + it('returns text for text input', () => { + expect(displayContentToString({ type: 'text', text: 'hello' })).toBe( + 'hello', + ); + }); + + it('renders a diff for diff input', () => { + const diff = { + type: 'diff' as const, + path: 'test.ts', + beforeText: 'old', + afterText: 'new', + }; + const rendered = displayContentToString(diff); + expect(rendered).toContain('--- test.ts\tOriginal'); + expect(rendered).toContain('+++ test.ts\tModified'); + }); + + it('stringifies unknown structured objects', () => { + const unknown = { + type: 'something_else', + data: 123, + } as unknown as DisplayContent; + expect(displayContentToString(unknown)).toBe(JSON.stringify(unknown)); + }); + }); +}); diff --git a/packages/core/src/agent/tool-display-utils.ts b/packages/core/src/agent/tool-display-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..070cd254d02e6e715fdbd67dcdb7decdde3c911a --- /dev/null +++ b/packages/core/src/agent/tool-display-utils.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Diff from 'diff'; +import type { + ToolInvocation, + ToolResult, + ToolResultDisplay, +} from '../tools/tools.js'; +import type { ToolDisplay, DisplayContent, DisplayDiff } from './types.js'; + +/** + * Populates a ToolDisplay object from a tool invocation and its result. + * This serves as a centralized bridge during the migration to tool-controlled display. + */ +export function populateToolDisplay({ + name, + invocation, + resultDisplay, + displayName, + display: prevDisplay, +}: { + name: string; + invocation?: ToolInvocation; + resultDisplay?: ToolResultDisplay; + displayName?: string; + display?: ToolDisplay; +}): ToolDisplay { + const display: ToolDisplay = { + name: displayName || name, + description: invocation?.getDescription?.(), + ...prevDisplay, + }; + + if (resultDisplay !== undefined && display.result === undefined) { + display.result = toolResultDisplayToDisplayContent(resultDisplay); + } + + return display; +} + +/** + * Converts a legacy ToolResultDisplay into the new DisplayContent format. + */ +export function toolResultDisplayToDisplayContent( + resultDisplay: ToolResultDisplay, +): DisplayContent { + if (typeof resultDisplay === 'string') { + return { type: 'text', text: resultDisplay }; + } + + // Handle FileDiff -> DisplayDiff + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'fileDiff' in resultDisplay && + 'newContent' in resultDisplay + ) { + return { + type: 'diff', + path: resultDisplay.filePath || resultDisplay.fileName, + beforeText: resultDisplay.originalContent ?? '', + afterText: resultDisplay.newContent, + }; + } + + // Fallback for other structured types (LsTool, GrepTool, etc.) + // These will be fully migrated in Step 5. + return { + type: 'text', + text: JSON.stringify(resultDisplay), + }; +} + +/** + * Renders a universal diff string from a DisplayDiff object. + */ +export function renderDisplayDiff(diff: DisplayDiff): string { + return Diff.createPatch( + diff.path || 'file', + diff.beforeText, + diff.afterText, + 'Original', + 'Modified', + { context: 3 }, + ); +} + +/** + * Converts a DisplayContent object into a string representation. + * Useful for fallback displays or non-interactive environments. + */ +export function displayContentToString( + display: DisplayContent | undefined | null, +): string | undefined { + if (!display) { + return undefined; + } + if (display.type === 'text') { + return display.text; + } + if (display.type === 'diff') { + return renderDisplayDiff(display); + } + return JSON.stringify(display); +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d41c466024bcfecf243835cb853d9188f150c59 --- /dev/null +++ b/packages/core/src/agent/types.ts @@ -0,0 +1,389 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AnsiOutput } from '../utils/terminalSerializer.js'; +import type { Kind } from '../tools/tools.js'; + +export type WithMeta = { _meta?: Record }; + +export type Unsubscribe = () => void; + +export interface AgentProtocol extends Trajectory { + /** + * Send data to the agent. Promise resolves when action is acknowledged. + * Returns the agent-activity `streamId` affected by the send. This may be a + * new stream if idle, an existing stream, or null if the send was + * acknowledged without starting agent activity. Emitted events should still + * remain correlated to a stream via their `streamId`. + * + * When a new stream is created by a send, the streamId MUST be returned + * before the `agent_start` event is emitted for the stream. + */ + send(payload: AgentSend): Promise<{ streamId: string | null }>; + + /** + * Subscribes the provided callback to all future events emitted by this + * session. Returns an unsubscribe function. + * + * @param callback The callback function to listen to events. + */ + subscribe(callback: (event: AgentEvent) => void): Unsubscribe; + + /** + * Aborts an active stream of agent activity. + */ + abort(): Promise; + + /** + * AgentProtocol implements the Trajectory interface and can retrieve existing events. + */ + readonly events: readonly AgentEvent[]; +} + +type RequireExactlyOne = { + [K in keyof T]: Required> & + Partial, never>>; +}[keyof T]; + +interface AgentSendPayloads { + message: { + content: ContentPart[]; + displayContent?: string; + }; + elicitations: ElicitationResponse[]; + update: { title?: string; model?: string; config?: Record }; + action: { type: string; data: unknown }; +} + +export type AgentSend = RequireExactlyOne & WithMeta; + +export interface Trajectory { + readonly events: readonly AgentEvent[]; +} + +export interface AgentEventCommon { + /** Unique id for the event. */ + id: string; + /** Identifies the subagent thread, omitted for "main thread" events. */ + threadId?: string; + /** Identifies the stream this event belongs to. */ + streamId: string; + /** ISO Timestamp for the time at which the event occurred. */ + timestamp: string; + /** The concrete type of the event. */ + type: string; + + /** Optional arbitrary metadata for the event. */ + _meta?: { + /** source of the event e.g. 'user' | 'ext:{ext_name}/hooks/{hook_name}' */ + source?: string; + [key: string]: unknown; + }; +} + +export type AgentEventData< + EventType extends keyof AgentEvents = keyof AgentEvents, +> = AgentEvents[EventType] & { type: EventType }; + +/** + * Mapped type that produces a proper discriminated union when `EventType` is + * the default (all keys), enabling `switch (event.type)` narrowing. + * When a specific EventType is provided, resolves to a single variant. + */ +export type AgentEvent< + EventType extends keyof AgentEvents = keyof AgentEvents, +> = { + [K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K }; +}[EventType]; + +export type AgentEventType = keyof AgentEvents; + +export interface AgentEvents { + /** MUST be the first event emitted in a session. */ + initialize: Initialize; + /** Updates configuration about the current session/agent. */ + session_update: SessionUpdate; + /** Message content provided by user, agent, or developer. */ + message: AgentMessage; + /** Event indicating the start of agent activity on a stream. */ + agent_start: AgentStart; + /** Event indicating the end of agent activity on a stream. */ + agent_end: AgentEnd; + /** Tool request issued by the agent. */ + tool_request: ToolRequest; + /** Tool update issued by the agent. */ + tool_update: ToolUpdate; + /** Tool response supplied by the agent. */ + tool_response: ToolResponse; + /** Elicitation request to be displayed to the user. */ + elicitation_request: ElicitationRequest; + /** User's response to an elicitation to be returned to the agent. */ + elicitation_response: ElicitationResponse; + /** Reports token usage information. */ + usage: Usage; + /** Report errors. */ + error: ErrorData; + /** Custom events for things not otherwise covered above. */ + custom: CustomEvent; +} + +/** Initializes a session by binding it to a specific agent and id. */ +export interface Initialize { + /** The unique identifier for the session. */ + sessionId: string; + /** The unique location of the workspace (usually an absolute filesystem path). */ + workspace: string; + /** The identifier of the agent being used for this session. */ + agentId: string; + /** The schema declared by the agent that can be used for configuration. */ + configSchema?: Record; +} + +/** Updates config such as selected model or session title. */ +export interface SessionUpdate { + /** If provided, updates the human-friendly title of the current session. */ + title?: string; + /** If provided, updates the model the current session should utilize. */ + model?: string; + /** If provided, updates agent-specific config information. */ + config?: Record; +} + +export type ContentPart = + /** Represents text. */ + ( + | { type: 'text'; text: string } + /** Represents model thinking output. */ + | { type: 'thought'; thought: string; thoughtSignature?: string } + /** Represents rich media (image/video/pdf/etc) included inline. */ + | { type: 'media'; data?: string; uri?: string; mimeType?: string } + /** Represents an inline reference to a resource, e.g. @-mention of a file */ + | { + type: 'reference'; + text: string; + data?: string; + uri?: string; + mimeType?: string; + } + ) & + WithMeta; + +export interface AgentMessage { + role: 'user' | 'agent' | 'developer'; + content: ContentPart[]; +} + +export type DisplayText = { type: 'text'; text: string }; +export type DisplayDiff = { + type: 'diff'; + path?: string; + beforeText: string; + afterText: string; +}; +export type DisplayTerminal = { + type: 'terminal'; + pid?: string; + exitCode?: number; + ansi?: AnsiOutput; +}; +export type DisplayAgent = { + type: 'agent'; + threadId: string; +}; + +export type DisplayContent = + | DisplayText + | DisplayDiff + | DisplayTerminal + | DisplayAgent; + +export type ToolDisplayFormat = + /** + * Displays as compact when user has enabled compact tools, box otherwise. + * This is the default format if none is selected. + **/ + | 'auto' + /** Always display this tool in compact format. */ + | 'compact' + /** Always display this tool in full box format. */ + | 'box' + /** Hide this tool from the event history. */ + | 'hidden' + /** Display this tool as a message-like notice. */ + | 'notice'; + +export interface ToolDisplay { + /** A display name for the tool. */ + name?: string; + /** A short description of what the tool is doing. */ + description?: string; + /** A short, one-line summary of the tool's results. */ + resultSummary?: string | null; + result?: DisplayContent | null; + /** A tool may specify its preferred display format. */ + format?: ToolDisplayFormat; +} + +export interface ToolRequest { + /** A unique identifier for this tool request to be correlated by the response. */ + requestId: string; + /** The name of the tool being requested. */ + name: string; + /** The arguments for the tool. */ + /** Tool-controlled display information. */ + display?: ToolDisplay; + args: Record; + /** UI specific metadata */ + _meta?: { + legacyState?: { + displayName?: string; + isOutputMarkdown?: boolean; + description?: string; + kind?: Kind; + }; + [key: string]: unknown; + }; +} + +/** + * Used to provide intermediate updates on long-running tools such as subagents + * or shell commands. ToolUpdates are ephemeral status reporting mechanisms only, + * they do not affect the final result sent to the model. + */ +export interface ToolUpdate { + requestId: string; + /** Tool-controlled display information. */ + display?: ToolDisplay; + content?: ContentPart[]; + data?: Record; + /** UI specific metadata */ + _meta?: { + legacyState?: { + status?: string; + progressMessage?: string; + progress?: number; + progressTotal?: number; + pid?: number; + description?: string; + }; + [key: string]: unknown; + }; +} + +export interface ToolResponse { + requestId: string; + name: string; + /** Tool-controlled display information. */ + display?: ToolDisplay; + /** Multi-part content to be sent to the model. */ + content?: ContentPart[]; + /** Structured data to be sent to the model. */ + data?: Record; + /** When true, the tool call encountered an error that will be sent to the model. */ + isError?: boolean; + /** UI specific metadata */ + _meta?: { + legacyState?: { + outputFile?: string; + }; + [key: string]: unknown; + }; +} + +export type ElicitationRequest = { + /** + * Whether the elicitation should be displayed as part of the message stream or + * as a standalone dialog box. + */ + display: 'inline' | 'modal'; + /** An optional heading/title for longer-form elicitation requests. */ + title?: string; + /** A unique ID for the elicitation request, correlated in response. */ + requestId: string; + /** The question / content to display to the user. */ + message: string; + requestedSchema: Record; +} & WithMeta; + +export type ElicitationResponse = { + requestId: string; + action: 'accept' | 'decline' | 'cancel'; + content: Record; +} & WithMeta; + +export interface ErrorData { + // One of https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto + status: // 400 + | 'INVALID_ARGUMENT' + | 'FAILED_PRECONDITION' + | 'OUT_OF_RANGE' + // 401 + | 'UNAUTHENTICATED' + // 403 + | 'PERMISSION_DENIED' + // 404 + | 'NOT_FOUND' + // 409 + | 'ABORTED' + | 'ALREADY_EXISTS' + // 429 + | 'RESOURCE_EXHAUSTED' + // 499 + | 'CANCELLED' + // 500 + | 'UNKNOWN' + | 'INTERNAL' + | 'DATA_LOSS' + // 501 + | 'UNIMPLEMENTED' + // 503 + | 'UNAVAILABLE' + // 504 + | 'DEADLINE_EXCEEDED' + | (string & {}); + /** User-facing message to be displayed. */ + message: string; + /** When true, agent execution is halting because of the error. */ + fatal: boolean; +} + +export interface Usage { + model: string; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + cost?: { amount: number; currency?: string }; +} + +export interface AgentStart { + streamId: string; +} + +export type StreamEndReason = + | 'completed' + | 'failed' + | 'aborted' + | 'max_turns' + | 'max_budget' + | 'max_time' + | 'refusal' + | 'elicitation' + | (string & {}); + +export interface AgentEnd { + streamId: string; + reason: StreamEndReason; + elicitationIds?: string[]; + /** End-of-stream summary data (cost, usage, turn count, refusal reason, etc.) */ + data?: Record; +} + +/** CustomEvents are kept in the trajectory but do not have any pre-defined purpose. */ +export interface CustomEvent { + /** A unique type for this custom event. */ + kind: string; + data?: Record; +} diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..60c9d66035d7a8a71c935fb968811cde88a99e09 --- /dev/null +++ b/packages/core/src/agents/a2a-client-manager.test.ts @@ -0,0 +1,570 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { A2AClientManager } from './a2a-client-manager.js'; +import type { AgentCard } from '@a2a-js/sdk'; +import { + ClientFactory, + DefaultAgentCardResolver, + createAuthenticatingFetchWithRetry, + ClientFactoryOptions, + type AuthenticationHandler, + type Client, +} from '@a2a-js/sdk/client'; +import type { Config } from '../config/config.js'; +import { Agent as UndiciAgent, ProxyAgent } from 'undici'; +import { debugLogger } from '../utils/debugLogger.js'; + +interface MockClient { + sendMessageStream: ReturnType; + getTask: ReturnType; + cancelTask: ReturnType; +} + +vi.mock('@a2a-js/sdk/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as Record), + createAuthenticatingFetchWithRetry: vi.fn(), + ClientFactory: vi.fn(), + DefaultAgentCardResolver: vi.fn(), + ClientFactoryOptions: { + createFrom: vi.fn(), + default: {}, + }, + }; +}); + +vi.mock('../utils/debugLogger.js', () => ({ + debugLogger: { + debug: vi.fn(), + }, +})); + +describe('A2AClientManager', () => { + let manager: A2AClientManager; + const mockAgentCard: AgentCard = { + name: 'test-agent', + description: 'A test agent', + url: 'http://test.agent', + version: '1.0.0', + protocolVersion: '0.1.0', + capabilities: {}, + skills: [], + defaultInputModes: [], + defaultOutputModes: [], + }; + + const mockClient: MockClient = { + sendMessageStream: vi.fn(), + getTask: vi.fn(), + cancelTask: vi.fn(), + }; + + const authFetchMock = vi.fn(); + const mockConfig = { + getProxy: vi.fn(), + } as unknown as Config; + + beforeEach(() => { + vi.clearAllMocks(); + manager = new A2AClientManager(mockConfig); + + // Re-create the instances as plain objects that can be spied on + const factoryInstance = { + createFromUrl: vi.fn(), + createFromAgentCard: vi.fn(), + }; + const resolverInstance = { + resolve: vi.fn(), + }; + + vi.mocked(ClientFactory).mockReturnValue( + factoryInstance as unknown as ClientFactory, + ); + vi.mocked(DefaultAgentCardResolver).mockReturnValue( + resolverInstance as unknown as DefaultAgentCardResolver, + ); + + vi.spyOn(factoryInstance, 'createFromUrl').mockResolvedValue( + mockClient as unknown as Client, + ); + vi.spyOn(factoryInstance, 'createFromAgentCard').mockResolvedValue( + mockClient as unknown as Client, + ); + vi.spyOn(resolverInstance, 'resolve').mockResolvedValue({ + ...mockAgentCard, + url: 'http://test.agent/real/endpoint', + } as AgentCard); + + vi.spyOn(ClientFactoryOptions, 'createFrom').mockImplementation( + (_defaults, overrides) => overrides as unknown as ClientFactoryOptions, + ); + + vi.mocked(createAuthenticatingFetchWithRetry).mockImplementation(() => + authFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response), + ); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('getInstance / dispatcher initialization', () => { + it('should use UndiciAgent when no proxy is configured', async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + + const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock + .calls[0][0]; + const cardFetch = resolverOptions?.fetchImpl as typeof fetch; + await cardFetch('http://test.agent/card'); + + const fetchCall = vi + .mocked(fetch) + .mock.calls.find((call) => call[0] === 'http://test.agent/card'); + expect(fetchCall).toBeDefined(); + expect( + (fetchCall![1] as { dispatcher?: unknown })?.dispatcher, + ).toBeInstanceOf(UndiciAgent); + expect( + (fetchCall![1] as { dispatcher?: unknown })?.dispatcher, + ).not.toBeInstanceOf(ProxyAgent); + }); + + it('should use ProxyAgent when a proxy is configured via Config', async () => { + const mockConfigWithProxy = { + getProxy: () => 'http://my-proxy:8080', + } as Config; + + manager = new A2AClientManager(mockConfigWithProxy); + await manager.loadAgent('TestProxyAgent', { + type: 'url', + url: 'http://test.proxy.agent/card', + }); + + const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock + .calls[0][0]; + const cardFetch = resolverOptions?.fetchImpl as typeof fetch; + await cardFetch('http://test.proxy.agent/card'); + + const fetchCall = vi + .mocked(fetch) + .mock.calls.find((call) => call[0] === 'http://test.proxy.agent/card'); + expect(fetchCall).toBeDefined(); + expect( + (fetchCall![1] as { dispatcher?: unknown })?.dispatcher, + ).toBeInstanceOf(ProxyAgent); + }); + }); + + describe('loadAgent', () => { + it('should create and cache an A2AClient', async () => { + const agentCard = await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + expect(manager.getAgentCard('TestAgent')).toBe(agentCard); + expect(manager.getClient('TestAgent')).toBeDefined(); + }); + + it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + expect(ClientFactoryOptions.createFrom).toHaveBeenCalled(); + }); + + it('should throw an error if an agent with the same name is already loaded', async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + await expect( + manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }), + ).rejects.toThrow("Agent with name 'TestAgent' is already loaded."); + }); + + it('should use native fetch by default', async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled(); + }); + + it('should use provided custom authentication handler for transports only', async () => { + const customAuthHandler = { + headers: vi.fn(), + shouldRetryWithHeaders: vi.fn(), + }; + await manager.loadAgent( + 'TestAgent', + { type: 'url', url: 'http://test.agent/card' }, + customAuthHandler as unknown as AuthenticationHandler, + ); + + // Card resolver should NOT use the authenticated fetch by default. + const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock + .calls[0][0]; + expect(resolverOptions?.fetchImpl).not.toBe(authFetchMock); + }); + + it('should use unauthenticated fetch for card resolver and avoid authenticated fetch if success', async () => { + const customAuthHandler = { + headers: vi.fn(), + shouldRetryWithHeaders: vi.fn(), + }; + await manager.loadAgent( + 'AuthCardAgent', + { type: 'url', url: 'http://authcard.agent/card' }, + customAuthHandler as unknown as AuthenticationHandler, + ); + + const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock + .calls[0][0]; + const cardFetch = resolverOptions?.fetchImpl as typeof fetch; + + expect(cardFetch).toBeDefined(); + + await cardFetch('http://test.url'); + + expect(fetch).toHaveBeenCalledWith('http://test.url', expect.anything()); + expect(authFetchMock).not.toHaveBeenCalled(); + }); + + it('should retry with authenticating fetch if agent card fetch returns 401', async () => { + const customAuthHandler = { + headers: vi.fn(), + shouldRetryWithHeaders: vi.fn(), + }; + + // Mock the initial unauthenticated fetch to fail with 401 + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({}), + } as Response); + + await manager.loadAgent( + 'AuthCardAgent401', + { type: 'url', url: 'http://authcard.agent/card' }, + customAuthHandler as unknown as AuthenticationHandler, + ); + + const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock + .calls[0][0]; + const cardFetch = resolverOptions?.fetchImpl as typeof fetch; + + await cardFetch('http://test.url'); + + expect(fetch).toHaveBeenCalledWith('http://test.url', expect.anything()); + expect(authFetchMock).toHaveBeenCalledWith('http://test.url', undefined); + }); + + it('should log a debug message upon loading an agent', async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining("Loaded agent 'TestAgent'"), + ); + }); + + it('should clear the cache', async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + manager.clearCache(); + expect(manager.getAgentCard('TestAgent')).toBeUndefined(); + expect(manager.getClient('TestAgent')).toBeUndefined(); + }); + + it('should load an agent from inline JSON without calling resolver', async () => { + const inlineJson = JSON.stringify(mockAgentCard); + const agentCard = await manager.loadAgent('JsonAgent', { + type: 'json', + json: inlineJson, + }); + expect(agentCard).toBeDefined(); + expect(agentCard.name).toBe('test-agent'); + expect(manager.getAgentCard('JsonAgent')).toBe(agentCard); + expect(manager.getClient('JsonAgent')).toBeDefined(); + // Resolver should not have been called for inline JSON + const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock + .results[0]?.value; + if (resolverInstance) { + expect(resolverInstance.resolve).not.toHaveBeenCalled(); + } + }); + + it('should throw a descriptive error for invalid inline JSON', async () => { + await expect( + manager.loadAgent('BadJsonAgent', { + type: 'json', + json: 'not valid json {{', + }), + ).rejects.toThrow( + /Failed to parse inline agent card JSON for agent 'BadJsonAgent'/, + ); + }); + + it('should log "inline JSON" for JSON-loaded agents', async () => { + const inlineJson = JSON.stringify(mockAgentCard); + await manager.loadAgent('JsonLogAgent', { + type: 'json', + json: inlineJson, + }); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('inline JSON'), + ); + }); + + it('should throw if resolveAgentCard fails', async () => { + const resolverInstance = { + resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')), + }; + vi.mocked(DefaultAgentCardResolver).mockReturnValue( + resolverInstance as unknown as DefaultAgentCardResolver, + ); + + await expect( + manager.loadAgent('FailAgent', { + type: 'url', + url: 'http://fail.agent', + }), + ).rejects.toThrow('Resolution failed'); + }); + + it('should throw if factory.createFromAgentCard fails', async () => { + const factoryInstance = { + createFromAgentCard: vi + .fn() + .mockRejectedValue(new Error('Factory failed')), + }; + vi.mocked(ClientFactory).mockReturnValue( + factoryInstance as unknown as ClientFactory, + ); + + await expect( + manager.loadAgent('FailAgent', { + type: 'url', + url: 'http://fail.agent', + }), + ).rejects.toThrow('Factory failed'); + }); + }); + + describe('getAgentCard and getClient', () => { + it('should return undefined if agent is not found', () => { + expect(manager.getAgentCard('Unknown')).toBeUndefined(); + expect(manager.getClient('Unknown')).toBeUndefined(); + }); + }); + + describe('sendMessageStream', () => { + beforeEach(async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + }); + + it('should send a message and return a stream', async () => { + mockClient.sendMessageStream.mockReturnValue( + (async function* () { + yield { kind: 'message' }; + })(), + ); + + const stream = manager.sendMessageStream('TestAgent', 'Hello'); + const results = []; + for await (const result of stream) { + results.push(result); + } + + expect(results).toHaveLength(1); + expect(mockClient.sendMessageStream).toHaveBeenCalled(); + }); + + it('should use contextId and taskId when provided', async () => { + mockClient.sendMessageStream.mockReturnValue( + (async function* () { + yield { kind: 'message' }; + })(), + ); + + const stream = manager.sendMessageStream('TestAgent', 'Hello', { + contextId: 'ctx123', + taskId: 'task456', + }); + // trigger execution + for await (const _ of stream) { + break; + } + + expect(mockClient.sendMessageStream).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + contextId: 'ctx123', + taskId: 'task456', + }), + }), + expect.any(Object), + ); + }); + + it('should correctly propagate AbortSignal to the stream', async () => { + mockClient.sendMessageStream.mockReturnValue( + (async function* () { + yield { kind: 'message' }; + })(), + ); + + const controller = new AbortController(); + const stream = manager.sendMessageStream('TestAgent', 'Hello', { + signal: controller.signal, + }); + // trigger execution + for await (const _ of stream) { + break; + } + + expect(mockClient.sendMessageStream).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it('should handle a multi-chunk stream with different event types', async () => { + mockClient.sendMessageStream.mockReturnValue( + (async function* () { + yield { kind: 'message', messageId: 'm1' }; + yield { kind: 'status-update', taskId: 't1' }; + })(), + ); + + const stream = manager.sendMessageStream('TestAgent', 'Hello'); + const results = []; + for await (const result of stream) { + results.push(result); + } + + expect(results).toHaveLength(2); + expect(results[0].kind).toBe('message'); + expect(results[1].kind).toBe('status-update'); + }); + + it('should throw prefixed error on failure', async () => { + mockClient.sendMessageStream.mockImplementation(() => { + throw new Error('Network failure'); + }); + + const stream = manager.sendMessageStream('TestAgent', 'Hello'); + await expect(async () => { + for await (const _ of stream) { + // empty + } + }).rejects.toThrow( + '[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure', + ); + }); + + it('should throw an error if the agent is not found', async () => { + const stream = manager.sendMessageStream('NonExistentAgent', 'Hello'); + await expect(async () => { + for await (const _ of stream) { + // empty + } + }).rejects.toThrow("Agent 'NonExistentAgent' not found."); + }); + }); + + describe('getTask', () => { + beforeEach(async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + }); + + it('should get a task from the correct agent', async () => { + const mockTask = { id: 'task123', kind: 'task' }; + mockClient.getTask.mockResolvedValue(mockTask); + + const result = await manager.getTask('TestAgent', 'task123'); + expect(result).toBe(mockTask); + expect(mockClient.getTask).toHaveBeenCalledWith({ id: 'task123' }); + }); + + it('should throw prefixed error on failure', async () => { + mockClient.getTask.mockRejectedValue(new Error('Not found')); + + await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow( + 'A2AClient getTask Error [TestAgent]: Not found', + ); + }); + + it('should throw an error if the agent is not found', async () => { + await expect( + manager.getTask('NonExistentAgent', 'task123'), + ).rejects.toThrow("Agent 'NonExistentAgent' not found."); + }); + }); + + describe('cancelTask', () => { + beforeEach(async () => { + await manager.loadAgent('TestAgent', { + type: 'url', + url: 'http://test.agent/card', + }); + }); + + it('should cancel a task on the correct agent', async () => { + const mockTask = { id: 'task123', kind: 'task' }; + mockClient.cancelTask.mockResolvedValue(mockTask); + + const result = await manager.cancelTask('TestAgent', 'task123'); + expect(result).toBe(mockTask); + expect(mockClient.cancelTask).toHaveBeenCalledWith({ id: 'task123' }); + }); + + it('should throw prefixed error on failure', async () => { + mockClient.cancelTask.mockRejectedValue(new Error('Cannot cancel')); + + await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow( + 'A2AClient cancelTask Error [TestAgent]: Cannot cancel', + ); + }); + + it('should throw an error if the agent is not found', async () => { + await expect( + manager.cancelTask('NonExistentAgent', 'task123'), + ).rejects.toThrow("Agent 'NonExistentAgent' not found."); + }); + }); +}); diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..a40e39f2f4ec2b756deed0b778876d8625a5146e --- /dev/null +++ b/packages/core/src/agents/a2a-client-manager.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AgentCard, + Message, + MessageSendParams, + Task, + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, +} from '@a2a-js/sdk'; +import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client'; +import { + ClientFactory, + ClientFactoryOptions, + DefaultAgentCardResolver, + JsonRpcTransportFactory, + RestTransportFactory, + createAuthenticatingFetchWithRetry, +} from '@a2a-js/sdk/client'; +import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc'; +import * as grpc from '@grpc/grpc-js'; +import { v4 as uuidv4 } from 'uuid'; +import { Agent as UndiciAgent, ProxyAgent } from 'undici'; +import { normalizeAgentCard } from './a2aUtils.js'; +import type { AgentCardLoadOptions } from './types.js'; +import type { Config } from '../config/config.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { classifyAgentError } from './a2a-errors.js'; + +/** + * Result of sending a message, which can be a full message, a task, + * or an incremental status/artifact update. + */ +export type SendMessageResult = + | Message + | Task + | TaskStatusUpdateEvent + | TaskArtifactUpdateEvent; + +// Remote agents can take 10+ minutes (e.g. Deep Research). +// Use a dedicated dispatcher so the global 5-min timeout isn't affected. +const A2A_TIMEOUT = 1800000; // 30 minutes + +/** + * Orchestrates communication with remote A2A agents. + * Manages protocol negotiation, authentication, and transport selection. + */ +export class A2AClientManager { + // Each agent should manage their own context/taskIds/card/etc + private clients = new Map(); + private agentCards = new Map(); + + private a2aDispatcher: UndiciAgent | ProxyAgent; + private a2aFetch: typeof fetch; + + constructor(private readonly config: Config) { + const proxyUrl = this.config.getProxy(); + const agentOptions = { + headersTimeout: A2A_TIMEOUT, + bodyTimeout: A2A_TIMEOUT, + }; + + if (proxyUrl) { + this.a2aDispatcher = new ProxyAgent({ + uri: proxyUrl, + ...agentOptions, + }); + } else { + this.a2aDispatcher = new UndiciAgent(agentOptions); + } + + this.a2aFetch = (input, init) => + fetch(input, { ...init, dispatcher: this.a2aDispatcher } as RequestInit); + } + + /** + * Loads an agent by fetching its AgentCard and caches the client. + * @param name The name to assign to the agent. + * @param agentCardUrl The full URL to the agent's card. + * @param authHandler Optional authentication handler to use for this agent. + * @returns The loaded AgentCard. + */ + async loadAgent( + name: string, + options: AgentCardLoadOptions, + authHandler?: AuthenticationHandler, + ): Promise { + if (this.clients.has(name) && this.agentCards.has(name)) { + throw new Error(`Agent with name '${name}' is already loaded.`); + } + + // Authenticated fetch for API calls (transports). + let authFetch: typeof fetch = this.a2aFetch; + if (authHandler) { + authFetch = createAuthenticatingFetchWithRetry( + this.a2aFetch, + authHandler, + ); + } + + // Use unauthenticated fetch for the agent card unless explicitly required. + // Some servers reject unexpected auth headers on the card endpoint (e.g. 400). + const cardFetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + // Try without auth first + const response = await this.a2aFetch(input, init); + + // Retry with auth if we hit a 401/403 + if ((response.status === 401 || response.status === 403) && authFetch) { + return authFetch(input, init); + } + + return response; + }; + + const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch }); + + let rawCard: unknown; + let urlIdentifier = 'inline JSON'; + + if (options.type === 'json') { + try { + rawCard = JSON.parse(options.json); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to parse inline agent card JSON for agent '${name}': ${msg}`, + ); + } + } else { + urlIdentifier = options.url; + rawCard = await resolver.resolve(options.url, ''); + } + + // TODO: Remove normalizeAgentCard once @a2a-js/sdk handles + // proto field name aliases (supportedInterfaces β†’ additionalInterfaces, + // protocolBinding β†’ transport). + const agentCard = normalizeAgentCard(rawCard); + + const grpcUrl = + agentCard.additionalInterfaces?.find((i) => i.transport === 'GRPC') + ?.url ?? agentCard.url; + + const clientOptions = ClientFactoryOptions.createFrom( + ClientFactoryOptions.default, + { + transports: [ + new RestTransportFactory({ fetchImpl: authFetch }), + new JsonRpcTransportFactory({ fetchImpl: authFetch }), + new GrpcTransportFactory({ + grpcChannelCredentials: grpcUrl.startsWith('https://') + ? grpc.credentials.createSsl() + : grpc.credentials.createInsecure(), + }), + ], + cardResolver: resolver, + }, + ); + + try { + const factory = new ClientFactory(clientOptions); + const client = await factory.createFromAgentCard(agentCard); + + this.clients.set(name, client); + this.agentCards.set(name, agentCard); + + debugLogger.debug( + `[A2AClientManager] Loaded agent '${name}' from ${urlIdentifier}`, + ); + + return agentCard; + } catch (error: unknown) { + throw classifyAgentError(name, urlIdentifier, error); + } + } + + /** + * Invalidates all cached clients and agent cards. + */ + clearCache(): void { + this.clients.clear(); + this.agentCards.clear(); + debugLogger.debug('[A2AClientManager] Cache cleared.'); + } + + /** + * Sends a message to a loaded agent and returns a stream of responses. + * @param agentName The name of the agent to send the message to. + * @param message The message content. + * @param options Optional context and task IDs to maintain conversation state. + * @returns An async iterable of responses from the agent (Message or Task). + * @throws Error if the agent returns an error response. + */ + async *sendMessageStream( + agentName: string, + message: string, + options?: { contextId?: string; taskId?: string; signal?: AbortSignal }, + ): AsyncIterable { + const client = this.clients.get(agentName); + if (!client) throw new Error(`Agent '${agentName}' not found.`); + + const messageParams: MessageSendParams = { + message: { + kind: 'message', + role: 'user', + messageId: uuidv4(), + parts: [{ kind: 'text', text: message }], + contextId: options?.contextId, + taskId: options?.taskId, + }, + }; + + try { + yield* client.sendMessageStream(messageParams, { + signal: options?.signal, + }); + } catch (error: unknown) { + const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`; + if (error instanceof Error) { + throw new Error(`${prefix}: ${error.message}`, { cause: error }); + } + throw new Error( + `${prefix}: Unexpected error during sendMessageStream: ${String(error)}`, + ); + } + } + + /** + * Retrieves a loaded agent card. + * @param name The name of the agent. + * @returns The agent card, or undefined if not found. + */ + getAgentCard(name: string): AgentCard | undefined { + return this.agentCards.get(name); + } + + /** + * Retrieves a loaded client. + * @param name The name of the agent. + * @returns The client, or undefined if not found. + */ + getClient(name: string): Client | undefined { + return this.clients.get(name); + } + + /** + * Retrieves a task from an agent. + * @param agentName The name of the agent. + * @param taskId The ID of the task to retrieve. + * @returns The task details. + */ + async getTask(agentName: string, taskId: string): Promise { + const client = this.clients.get(agentName); + if (!client) throw new Error(`Agent '${agentName}' not found.`); + try { + return await client.getTask({ id: taskId }); + } catch (error: unknown) { + const prefix = `A2AClient getTask Error [${agentName}]`; + if (error instanceof Error) { + throw new Error(`${prefix}: ${error.message}`, { cause: error }); + } + throw new Error(`${prefix}: Unexpected error: ${String(error)}`); + } + } + + /** + * Cancels a task on an agent. + * @param agentName The name of the agent. + * @param taskId The ID of the task to cancel. + * @returns The cancellation response. + */ + async cancelTask(agentName: string, taskId: string): Promise { + const client = this.clients.get(agentName); + if (!client) throw new Error(`Agent '${agentName}' not found.`); + try { + return await client.cancelTask({ id: taskId }); + } catch (error: unknown) { + const prefix = `A2AClient cancelTask Error [${agentName}]`; + if (error instanceof Error) { + throw new Error(`${prefix}: ${error.message}`, { cause: error }); + } + throw new Error(`${prefix}: Unexpected error: ${String(error)}`); + } + } +} diff --git a/packages/core/src/agents/a2a-errors.test.ts b/packages/core/src/agents/a2a-errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5039c57279d0498298bd90baee0d4470ad9e767 --- /dev/null +++ b/packages/core/src/agents/a2a-errors.test.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + A2AAgentError, + AgentCardNotFoundError, + AgentCardAuthError, + AgentAuthConfigMissingError, + AgentConnectionError, + classifyAgentError, +} from './a2a-errors.js'; + +describe('A2A Error Types', () => { + describe('A2AAgentError', () => { + it('should set name, agentName, and userMessage', () => { + const error = new A2AAgentError('my-agent', 'internal msg', 'user msg'); + expect(error.name).toBe('A2AAgentError'); + expect(error.agentName).toBe('my-agent'); + expect(error.message).toBe('internal msg'); + expect(error.userMessage).toBe('user msg'); + }); + }); + + describe('AgentCardNotFoundError', () => { + it('should produce a user-friendly 404 message', () => { + const error = new AgentCardNotFoundError( + 'my-agent', + 'https://example.com/card', + ); + expect(error.name).toBe('AgentCardNotFoundError'); + expect(error.agentName).toBe('my-agent'); + expect(error.userMessage).toContain('404'); + expect(error.userMessage).toContain('https://example.com/card'); + expect(error.userMessage).toContain('agent_card_url'); + }); + }); + + describe('AgentCardAuthError', () => { + it('should produce a user-friendly 401 message', () => { + const error = new AgentCardAuthError( + 'secure-agent', + 'https://example.com/card', + 401, + ); + expect(error.name).toBe('AgentCardAuthError'); + expect(error.statusCode).toBe(401); + expect(error.userMessage).toContain('401'); + expect(error.userMessage).toContain('Unauthorized'); + expect(error.userMessage).toContain('"auth" configuration'); + }); + + it('should produce a user-friendly 403 message', () => { + const error = new AgentCardAuthError( + 'secure-agent', + 'https://example.com/card', + 403, + ); + expect(error.statusCode).toBe(403); + expect(error.userMessage).toContain('403'); + expect(error.userMessage).toContain('Forbidden'); + }); + }); + + describe('AgentAuthConfigMissingError', () => { + it('should list missing config fields', () => { + const error = new AgentAuthConfigMissingError( + 'api-agent', + 'API Key (x-api-key): Send x-api-key in header', + [ + 'Authentication is required but not configured', + "Scheme 'api_key' requires apiKey authentication", + ], + ); + expect(error.name).toBe('AgentAuthConfigMissingError'); + expect(error.requiredAuth).toContain('API Key'); + expect(error.missingFields).toHaveLength(2); + expect(error.userMessage).toContain('API Key'); + expect(error.userMessage).toContain('no auth is configured'); + expect(error.userMessage).toContain('Missing:'); + }); + }); + + describe('AgentConnectionError', () => { + it('should wrap the original error cause', () => { + const cause = new Error('ECONNREFUSED'); + const error = new AgentConnectionError( + 'my-agent', + 'https://example.com/card', + cause, + ); + expect(error.name).toBe('AgentConnectionError'); + expect(error.userMessage).toContain('ECONNREFUSED'); + expect(error.userMessage).toContain('https://example.com/card'); + }); + + it('should handle non-Error causes', () => { + const error = new AgentConnectionError( + 'my-agent', + 'https://example.com/card', + 'raw string error', + ); + expect(error.userMessage).toContain('raw string error'); + }); + }); + + describe('classifyAgentError', () => { + it('should classify a 404 error message', () => { + const raw = new Error('HTTP 404: Not Found'); + const result = classifyAgentError( + 'agent-a', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardNotFoundError); + expect(result.agentName).toBe('agent-a'); + }); + + it('should classify a "not found" error message (case-insensitive)', () => { + const raw = new Error('Agent card not found at the given URL'); + const result = classifyAgentError( + 'agent-a', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardNotFoundError); + }); + + it('should classify a 401 error message', () => { + const raw = new Error('Request failed with status 401'); + const result = classifyAgentError( + 'agent-b', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(401); + }); + + it('should classify an "unauthorized" error message', () => { + const raw = new Error('Unauthorized access to agent card'); + const result = classifyAgentError( + 'agent-b', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + }); + + it('should classify a 403 error message', () => { + const raw = new Error('HTTP 403 Forbidden'); + const result = classifyAgentError( + 'agent-c', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(403); + }); + + it('should fall back to AgentConnectionError for unknown errors', () => { + const raw = new Error('Something completely unexpected'); + const result = classifyAgentError( + 'agent-d', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + it('should classify ECONNREFUSED as AgentConnectionError', () => { + const raw = new Error('ECONNREFUSED 127.0.0.1:8080'); + const result = classifyAgentError( + 'agent-d', + 'https://example.com/card', + raw, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + it('should handle non-Error values', () => { + const result = classifyAgentError( + 'agent-e', + 'https://example.com/card', + 'some string error', + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + describe('cause chain inspection', () => { + it('should detect 404 in a nested cause', () => { + const inner = new Error('HTTP 404 Not Found'); + const outer = new Error('fetch failed', { cause: inner }); + const result = classifyAgentError( + 'agent-nested', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardNotFoundError); + }); + + it('should detect 401 in a deeply nested cause', () => { + const innermost = new Error('Server returned 401'); + const middle = new Error('Request error', { cause: innermost }); + const outer = new Error('fetch failed', { cause: middle }); + const result = classifyAgentError( + 'agent-deep', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(401); + }); + + it('should detect ECONNREFUSED error code in cause chain', () => { + const inner = Object.assign(new Error('connect failed'), { + code: 'ECONNREFUSED', + }); + const outer = new Error('fetch failed', { cause: inner }); + const result = classifyAgentError( + 'agent-conn', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + }); + + it('should detect status property on error objects in cause chain', () => { + const inner = Object.assign(new Error('Bad response'), { + status: 403, + }); + const outer = new Error('agent card resolution failed', { + cause: inner, + }); + const result = classifyAgentError( + 'agent-status', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(403); + }); + + it('should detect status on a plain-object cause (non-Error)', () => { + const outer = new Error('fetch failed'); + // Some HTTP libs set cause to a plain object, not an Error instance + (outer as unknown as { cause: unknown }).cause = { + message: 'Unauthorized', + status: 401, + }; + const result = classifyAgentError( + 'agent-plain-cause', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(401); + }); + + it('should detect statusCode on a plain-object cause (non-Error)', () => { + const outer = new Error('fetch failed'); + (outer as unknown as { cause: unknown }).cause = { + message: 'Forbidden', + statusCode: 403, + }; + const result = classifyAgentError( + 'agent-plain-cause-403', + 'https://example.com/card', + outer, + ); + expect(result).toBeInstanceOf(AgentCardAuthError); + expect((result as AgentCardAuthError).statusCode).toBe(403); + }); + + it('should classify ENOTFOUND as AgentConnectionError, not 404', () => { + // ENOTFOUND (DNS resolution failure) should NOT be misclassified + // as a 404 despite containing "NOTFOUND" in the error code. + const inner = Object.assign( + new Error('getaddrinfo ENOTFOUND example.invalid'), + { + code: 'ENOTFOUND', + }, + ); + const outer = new Error('fetch failed', { cause: inner }); + const result = classifyAgentError( + 'agent-dns', + 'https://example.invalid/card', + outer, + ); + expect(result).toBeInstanceOf(AgentConnectionError); + expect(result).not.toBeInstanceOf(AgentCardNotFoundError); + }); + }); + }); +}); diff --git a/packages/core/src/agents/a2a-errors.ts b/packages/core/src/agents/a2a-errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..4023d5fdbd8b7cad63bf8a0230dd53f67a25fc93 --- /dev/null +++ b/packages/core/src/agents/a2a-errors.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Custom error types for A2A remote agent operations. + * Provides structured, user-friendly error messages for common failure modes + * during agent card fetching, authentication, and communication. + */ + +/** + * Base class for all A2A agent errors. + * Provides a `userMessage` field with a human-readable description. + */ +export class A2AAgentError extends Error { + /** A user-friendly message suitable for display in the CLI. */ + readonly userMessage: string; + /** The agent name associated with this error. */ + readonly agentName: string; + + constructor( + agentName: string, + message: string, + userMessage: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'A2AAgentError'; + this.agentName = agentName; + this.userMessage = userMessage; + } +} + +/** + * Thrown when the agent card URL returns a 404 Not Found response. + */ +export class AgentCardNotFoundError extends A2AAgentError { + constructor(agentName: string, agentCardUrl: string) { + const message = `Agent card not found at ${agentCardUrl} (HTTP 404)`; + const userMessage = `Agent card not found (404) at ${agentCardUrl}. Verify the agent_card_url in your agent definition.`; + super(agentName, message, userMessage); + this.name = 'AgentCardNotFoundError'; + } +} + +/** + * Thrown when the agent card URL returns a 401/403 response, + * indicating an authentication or authorization failure. + */ +export class AgentCardAuthError extends A2AAgentError { + readonly statusCode: number; + + constructor(agentName: string, agentCardUrl: string, statusCode: 401 | 403) { + const statusText = statusCode === 401 ? 'Unauthorized' : 'Forbidden'; + const message = `Agent card request returned ${statusCode} ${statusText} for ${agentCardUrl}`; + const userMessage = `Authentication failed (${statusCode} ${statusText}) at ${agentCardUrl}. Check the "auth" configuration in your agent definition.`; + super(agentName, message, userMessage); + this.name = 'AgentCardAuthError'; + this.statusCode = statusCode; + } +} + +/** + * Thrown when the agent card's security schemes require authentication + * but the agent definition does not include the necessary auth configuration. + */ +export class AgentAuthConfigMissingError extends A2AAgentError { + /** Human-readable description of required authentication schemes. */ + readonly requiredAuth: string; + /** Specific fields or config entries that are missing. */ + readonly missingFields: string[]; + + constructor( + agentName: string, + requiredAuth: string, + missingFields: string[], + ) { + const message = `Agent "${agentName}" requires authentication but none is configured`; + const userMessage = `Agent requires ${requiredAuth} but no auth is configured. Missing: ${missingFields.join(', ')}`; + super(agentName, message, userMessage); + this.name = 'AgentAuthConfigMissingError'; + this.requiredAuth = requiredAuth; + this.missingFields = missingFields; + } +} + +/** + * Thrown when a generic/unexpected network or server error occurs + * while fetching the agent card or communicating with the remote agent. + */ +export class AgentConnectionError extends A2AAgentError { + constructor(agentName: string, agentCardUrl: string, cause: unknown) { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + const message = `Failed to connect to agent "${agentName}" at ${agentCardUrl}: ${causeMessage}`; + const userMessage = `Connection failed for ${agentCardUrl}: ${causeMessage}`; + super(agentName, message, userMessage, { cause }); + this.name = 'AgentConnectionError'; + } +} + +/** Shape of an error-like object in a cause chain (Error, HTTP response, or plain object). */ +interface ErrorLikeObject { + message?: string; + code?: string; + status?: number; + statusCode?: number; + cause?: unknown; +} + +/** Type guard for objects that may carry error metadata (message, code, status, cause). */ +function isErrorLikeObject(val: unknown): val is ErrorLikeObject { + return typeof val === 'object' && val !== null; +} + +/** + * Collects all error messages from an error's cause chain into a single string + * for pattern matching. This is necessary because the A2A SDK and Node's fetch + * often wrap the real error (e.g. HTTP status) deep inside nested causes. + */ +function collectErrorMessages(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + let depth = 0; + const maxDepth = 10; + + while (current && depth < maxDepth) { + if (isErrorLikeObject(current)) { + // Save reference before instanceof narrows the type from ErrorLikeObject to Error. + const obj = current; + + if (current instanceof Error) { + parts.push(current.message); + } else if (typeof obj.message === 'string') { + parts.push(obj.message); + } + + if (typeof obj.code === 'string') { + parts.push(obj.code); + } + + if (typeof obj.status === 'number') { + parts.push(String(obj.status)); + } else if (typeof obj.statusCode === 'number') { + parts.push(String(obj.statusCode)); + } + + current = obj.cause; + } else if (typeof current === 'string') { + parts.push(current); + break; + } else { + parts.push(String(current)); + break; + } + depth++; + } + + return parts.join(' '); +} + +/** + * Attempts to classify a raw error from the A2A SDK into a typed A2AAgentError. + * + * Inspects the error message and full cause chain for HTTP status codes and + * well-known patterns to produce a structured, user-friendly error. + * + * @param agentName The name of the agent being loaded. + * @param agentCardUrl The URL of the agent card. + * @param error The raw error caught during agent loading. + * @returns A classified A2AAgentError subclass. + */ +export function classifyAgentError( + agentName: string, + agentCardUrl: string, + error: unknown, +): A2AAgentError { + // Collect messages from the entire cause chain for thorough matching. + const fullErrorText = collectErrorMessages(error); + + // Check for well-known connection error codes in the cause chain. + // NOTE: This is checked before the 404 pattern as a defensive measure + // to prevent DNS errors (ENOTFOUND) from being misclassified as 404s. + if ( + /\b(ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT)\b/i.test(fullErrorText) + ) { + return new AgentConnectionError(agentName, agentCardUrl, error); + } + + // Check for HTTP status code patterns across the full cause chain. + if (/\b404\b|\bnot[\s_-]?found\b/i.test(fullErrorText)) { + return new AgentCardNotFoundError(agentName, agentCardUrl); + } + + if (/\b401\b|unauthorized/i.test(fullErrorText)) { + return new AgentCardAuthError(agentName, agentCardUrl, 401); + } + + if (/\b403\b|forbidden/i.test(fullErrorText)) { + return new AgentCardAuthError(agentName, agentCardUrl, 403); + } + + // Fallback to a generic connection error. + return new AgentConnectionError(agentName, agentCardUrl, error); +} diff --git a/packages/core/src/agents/a2aUtils.test.ts b/packages/core/src/agents/a2aUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..543acd105021a26417926e41755de1c85918c45f --- /dev/null +++ b/packages/core/src/agents/a2aUtils.test.ts @@ -0,0 +1,611 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + extractMessageText, + extractIdsFromResponse, + isTerminalState, + A2AResultReassembler, + AUTH_REQUIRED_MSG, + normalizeAgentCard, +} from './a2aUtils.js'; +import type { SendMessageResult } from './a2a-client-manager.js'; +import type { + Message, + Task, + TextPart, + DataPart, + FilePart, + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, +} from '@a2a-js/sdk'; + +describe('a2aUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isTerminalState', () => { + it('should return true for completed, failed, canceled, and rejected', () => { + expect(isTerminalState('completed')).toBe(true); + expect(isTerminalState('failed')).toBe(true); + expect(isTerminalState('canceled')).toBe(true); + expect(isTerminalState('rejected')).toBe(true); + }); + + it('should return false for working, submitted, input-required, auth-required, and unknown', () => { + expect(isTerminalState('working')).toBe(false); + expect(isTerminalState('submitted')).toBe(false); + expect(isTerminalState('input-required')).toBe(false); + expect(isTerminalState('auth-required')).toBe(false); + expect(isTerminalState('unknown')).toBe(false); + expect(isTerminalState(undefined)).toBe(false); + }); + }); + + describe('extractIdsFromResponse', () => { + it('should extract IDs from a message response', () => { + const message: Message = { + kind: 'message', + role: 'agent', + messageId: 'm1', + contextId: 'ctx-1', + taskId: 'task-1', + parts: [], + }; + + const result = extractIdsFromResponse(message); + expect(result).toEqual({ + contextId: 'ctx-1', + taskId: 'task-1', + clearTaskId: false, + }); + }); + + it('should extract IDs from an in-progress task response', () => { + const task: Task = { + id: 'task-2', + contextId: 'ctx-2', + kind: 'task', + status: { state: 'working' }, + }; + + const result = extractIdsFromResponse(task); + expect(result).toEqual({ + contextId: 'ctx-2', + taskId: 'task-2', + clearTaskId: false, + }); + }); + + it('should set clearTaskId true for terminal task response', () => { + const task: Task = { + id: 'task-3', + contextId: 'ctx-3', + kind: 'task', + status: { state: 'completed' }, + }; + + const result = extractIdsFromResponse(task); + expect(result.clearTaskId).toBe(true); + }); + + it('should set clearTaskId true for terminal status update', () => { + const update = { + kind: 'status-update', + contextId: 'ctx-4', + taskId: 'task-4', + final: true, + status: { state: 'failed' }, + }; + + const result = extractIdsFromResponse( + update as unknown as TaskStatusUpdateEvent, + ); + expect(result.contextId).toBe('ctx-4'); + expect(result.taskId).toBe('task-4'); + expect(result.clearTaskId).toBe(true); + }); + + it('should extract IDs from an artifact-update event', () => { + const update = { + kind: 'artifact-update', + taskId: 'task-5', + contextId: 'ctx-5', + artifact: { + artifactId: 'art-1', + parts: [{ kind: 'text', text: 'artifact content' }], + }, + } as unknown as TaskArtifactUpdateEvent; + + const result = extractIdsFromResponse(update); + expect(result).toEqual({ + contextId: 'ctx-5', + taskId: 'task-5', + clearTaskId: false, + }); + }); + + it('should extract taskId from status update event', () => { + const update = { + kind: 'status-update', + taskId: 'task-6', + contextId: 'ctx-6', + final: false, + status: { state: 'working' }, + }; + + const result = extractIdsFromResponse( + update as unknown as TaskStatusUpdateEvent, + ); + expect(result.taskId).toBe('task-6'); + expect(result.contextId).toBe('ctx-6'); + expect(result.clearTaskId).toBe(false); + }); + }); + + describe('extractMessageText', () => { + it('should extract text from simple text parts', () => { + const message: Message = { + kind: 'message', + role: 'user', + messageId: '1', + parts: [ + { kind: 'text', text: 'Hello' } as TextPart, + { kind: 'text', text: 'World' } as TextPart, + ], + }; + expect(extractMessageText(message)).toBe('Hello\nWorld'); + }); + + it('should extract data from data parts', () => { + const message: Message = { + kind: 'message', + role: 'user', + messageId: '1', + parts: [{ kind: 'data', data: { foo: 'bar' } } as DataPart], + }; + expect(extractMessageText(message)).toBe('Data: {"foo":"bar"}'); + }); + + it('should extract file info from file parts', () => { + const message: Message = { + kind: 'message', + role: 'user', + messageId: '1', + parts: [ + { + kind: 'file', + file: { + name: 'test.txt', + uri: 'file://test.txt', + mimeType: 'text/plain', + }, + } as FilePart, + { + kind: 'file', + file: { + uri: 'http://example.com/doc', + mimeType: 'application/pdf', + }, + } as FilePart, + ], + }; + // The formatting logic in a2aUtils prefers name over uri + expect(extractMessageText(message)).toContain('File: test.txt'); + expect(extractMessageText(message)).toContain( + 'File: http://example.com/doc', + ); + }); + + it('should handle mixed parts', () => { + const message: Message = { + kind: 'message', + role: 'user', + messageId: '1', + parts: [ + { kind: 'text', text: 'Here is data:' } as TextPart, + { kind: 'data', data: { value: 123 } } as DataPart, + ], + }; + expect(extractMessageText(message)).toBe( + 'Here is data:\nData: {"value":123}', + ); + }); + + it('should return empty string for undefined or empty message', () => { + expect(extractMessageText(undefined)).toBe(''); + expect( + extractMessageText({ + kind: 'message', + role: 'user', + messageId: '1', + parts: [], + } as Message), + ).toBe(''); + }); + + it('should handle file parts with neither name nor uri', () => { + const message: Message = { + kind: 'message', + role: 'user', + messageId: '1', + parts: [ + { + kind: 'file', + file: { + mimeType: 'text/plain', + }, + } as FilePart, + ], + }; + expect(extractMessageText(message)).toBe('File: [binary/unnamed]'); + }); + }); + + describe('normalizeAgentCard', () => { + it('should throw if input is not an object', () => { + expect(() => normalizeAgentCard(null)).toThrow('Agent card is missing.'); + expect(() => normalizeAgentCard(undefined)).toThrow( + 'Agent card is missing.', + ); + expect(() => normalizeAgentCard('not an object')).toThrow( + 'Agent card is missing.', + ); + }); + + it('should preserve unknown fields while providing defaults for mandatory ones', () => { + const raw = { + name: 'my-agent', + customField: 'keep-me', + }; + + const normalized = normalizeAgentCard(raw); + + expect(normalized.name).toBe('my-agent'); + // @ts-expect-error - testing dynamic preservation + expect(normalized.customField).toBe('keep-me'); + expect(normalized.description).toBeUndefined(); + expect(normalized.skills).toBeUndefined(); + expect(normalized.defaultInputModes).toBeUndefined(); + }); + + it('should map supportedInterfaces to additionalInterfaces with protocolBinding β†’ transport', () => { + const raw = { + name: 'test', + supportedInterfaces: [ + { + url: 'grpc://test', + protocolBinding: 'GRPC', + protocolVersion: '1.0', + }, + ], + }; + + const normalized = normalizeAgentCard(raw); + + expect(normalized.additionalInterfaces).toHaveLength(1); + + const intf = normalized.additionalInterfaces?.[0] as unknown as Record< + string, + unknown + >; + + expect(intf['transport']).toBe('GRPC'); + expect(intf['url']).toBe('grpc://test'); + }); + + it('should not overwrite additionalInterfaces if already present', () => { + const raw = { + name: 'test', + additionalInterfaces: [{ url: 'http://grpc', transport: 'GRPC' }], + supportedInterfaces: [{ url: 'http://other', transport: 'REST' }], + }; + + const normalized = normalizeAgentCard(raw); + expect(normalized.additionalInterfaces).toHaveLength(1); + expect(normalized.additionalInterfaces?.[0].url).toBe('http://grpc'); + }); + + it('should NOT override existing transport if protocolBinding is also present', () => { + const raw = { + name: 'priority-test', + supportedInterfaces: [ + { url: 'foo', transport: 'GRPC', protocolBinding: 'REST' }, + ], + }; + const normalized = normalizeAgentCard(raw); + expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC'); + }); + + it('should not mutate the original card object', () => { + const raw = { + name: 'test', + supportedInterfaces: [{ url: 'grpc://test', protocolBinding: 'GRPC' }], + }; + + const normalized = normalizeAgentCard(raw); + expect(normalized).not.toBe(raw); + expect(normalized.additionalInterfaces).toBeDefined(); + // Original should not have additionalInterfaces added + expect( + (raw as Record)['additionalInterfaces'], + ).toBeUndefined(); + }); + }); + + describe('A2AResultReassembler', () => { + it('should reassemble sequential messages and incremental artifacts', () => { + const reassembler = new A2AResultReassembler(); + + // 1. Initial status + reassembler.update({ + kind: 'status-update', + taskId: 't1', + contextId: 'ctx1', + status: { + state: 'working', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Analyzing...' }], + } as Message, + }, + } as unknown as SendMessageResult); + + // 2. First artifact chunk + reassembler.update({ + kind: 'artifact-update', + taskId: 't1', + contextId: 'ctx1', + append: false, + artifact: { + artifactId: 'a1', + name: 'Code', + parts: [{ kind: 'text', text: 'print(' }], + }, + } as unknown as SendMessageResult); + + // 3. Second status + reassembler.update({ + kind: 'status-update', + taskId: 't1', + contextId: 'ctx1', + status: { + state: 'working', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Processing...' }], + } as Message, + }, + } as unknown as SendMessageResult); + + // 4. Second artifact chunk (append) + reassembler.update({ + kind: 'artifact-update', + taskId: 't1', + contextId: 'ctx1', + append: true, + artifact: { + artifactId: 'a1', + parts: [{ kind: 'text', text: '"Done")' }], + }, + } as unknown as SendMessageResult); + + const output = reassembler.toString(); + expect(output).toBe( + 'Analyzing...Processing...\n\nArtifact (Code):\nprint("Done")', + ); + }); + + it('should handle auth-required state with a message', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'status-update', + contextId: 'ctx1', + status: { + state: 'auth-required', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'I need your permission.' }], + } as Message, + }, + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toContain('I need your permission.'); + expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG); + }); + + it('should handle auth-required state without relying on metadata', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'status-update', + contextId: 'ctx1', + status: { + state: 'auth-required', + }, + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG); + }); + + it('should not duplicate the auth instruction OR agent message if multiple identical auth-required chunks arrive', () => { + const reassembler = new A2AResultReassembler(); + + const chunk = { + kind: 'status-update', + contextId: 'ctx1', + status: { + state: 'auth-required', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'You need to login here.' }], + } as Message, + }, + } as unknown as SendMessageResult; + + reassembler.update(chunk); + // Simulate multiple updates with the same overall state + reassembler.update(chunk); + reassembler.update(chunk); + + const output = reassembler.toString(); + // The substring should only appear exactly once + expect(output.split(AUTH_REQUIRED_MSG).length - 1).toBe(1); + + // Crucially, the agent's actual custom message should ALSO only appear exactly once + expect(output.split('You need to login here.').length - 1).toBe(1); + }); + + it('should fallback to history in a task chunk if no message or artifacts exist and task is terminal', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'task', + id: 'task-1', + contextId: 'ctx1', + status: { state: 'completed' }, + history: [ + { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Answer from history' }], + } as Message, + ], + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toBe('Answer from history'); + }); + + it('should NOT fallback to history in a task chunk if task is not terminal', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'task', + id: 'task-1', + contextId: 'ctx1', + status: { state: 'working' }, + history: [ + { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Answer from history' }], + } as Message, + ], + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toBe(''); + }); + + it('should not fallback to history if artifacts exist', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'task', + id: 'task-1', + contextId: 'ctx1', + status: { state: 'completed' }, + artifacts: [ + { + artifactId: 'art-1', + name: 'Data', + parts: [{ kind: 'text', text: 'Artifact Content' }], + }, + ], + history: [ + { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Answer from history' }], + } as Message, + ], + } as unknown as SendMessageResult); + + const output = reassembler.toString(); + expect(output).toContain('Artifact (Data):'); + expect(output).not.toContain('Answer from history'); + }); + + it('should return message log as activity items', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'status-update', + taskId: 't1', + contextId: 'ctx1', + status: { + state: 'working', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Message 1' }], + } as Message, + }, + } as unknown as SendMessageResult); + + reassembler.update({ + kind: 'status-update', + taskId: 't1', + contextId: 'ctx1', + status: { + state: 'working', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Message 2' }], + } as Message, + }, + } as unknown as SendMessageResult); + + const items = reassembler.toActivityItems(); + expect(items).toHaveLength(2); + expect(items[0]).toEqual({ + id: 'msg-0', + type: 'thought', + content: 'Message 1', + status: 'completed', + }); + expect(items[1]).toEqual({ + id: 'msg-1', + type: 'thought', + content: 'Message 2', + status: 'completed', + }); + }); + + it('should correctly push the first message when messageLog is empty (Issue #24894)', () => { + const reassembler = new A2AResultReassembler(); + + const message: Message = { + kind: 'message', + role: 'agent', + messageId: 'm1', + parts: [{ kind: 'text', text: 'First message' }], + }; + + reassembler.update({ + kind: 'status-update', + contextId: 'ctx1', + status: { + state: 'working', + message, + }, + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toBe('First message'); + }); + }); +}); diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..876f6239114fe55806bcdef956592cdb6c122170 --- /dev/null +++ b/packages/core/src/agents/a2aUtils.ts @@ -0,0 +1,368 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Message, + Part, + TextPart, + DataPart, + FilePart, + Artifact, + TaskState, + AgentCard, + AgentInterface, +} from '@a2a-js/sdk'; +import type { SendMessageResult } from './a2a-client-manager.js'; +import { type SubagentActivityItem, SubagentState } from './types.js'; + +export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`; + +/** + * Reassembles incremental A2A streaming updates into a coherent result. + * Shows sequential status/messages followed by all reassembled artifacts. + */ +export class A2AResultReassembler { + private messageLog: string[] = []; + private artifacts = new Map(); + private artifactChunks = new Map(); + + /** + * Processes a new chunk from the A2A stream. + */ + update(chunk: SendMessageResult) { + if (!('kind' in chunk)) return; + + switch (chunk.kind) { + case 'status-update': + this.appendStateInstructions(chunk.status?.state); + this.pushMessage(chunk.status?.message); + break; + + case 'artifact-update': + if (chunk.artifact) { + const id = chunk.artifact.artifactId; + const existing = this.artifacts.get(id); + + if (chunk.append && existing) { + for (const part of chunk.artifact.parts) { + existing.parts.push(structuredClone(part)); + } + } else { + this.artifacts.set(id, structuredClone(chunk.artifact)); + } + + const newText = extractPartsText(chunk.artifact.parts, ''); + let chunks = this.artifactChunks.get(id); + if (!chunks) { + chunks = []; + this.artifactChunks.set(id, chunks); + } + if (chunk.append) { + chunks.push(newText); + } else { + chunks.length = 0; + chunks.push(newText); + } + } + break; + + case 'task': + this.appendStateInstructions(chunk.status?.state); + this.pushMessage(chunk.status?.message); + if (chunk.artifacts) { + for (const art of chunk.artifacts) { + this.artifacts.set(art.artifactId, structuredClone(art)); + this.artifactChunks.set(art.artifactId, [ + extractPartsText(art.parts, ''), + ]); + } + } + // History Fallback: Some agent implementations do not populate the + // status.message in their final terminal response, instead archiving + // the final answer in the task's history array. To ensure we don't + // present an empty result, we fallback to the most recent agent message + // in the history only when the task is terminal and no other content + // (message log or artifacts) has been reassembled. + if ( + isTerminalState(chunk.status?.state) && + this.messageLog.length === 0 && + this.artifacts.size === 0 && + chunk.history && + chunk.history.length > 0 + ) { + const lastAgentMsg = [...chunk.history] + .reverse() + .find((m) => m.role?.toLowerCase().includes('agent')); + if (lastAgentMsg) { + this.pushMessage(lastAgentMsg); + } + } + break; + + case 'message': + this.pushMessage(chunk); + break; + default: + // Handle unknown kinds gracefully + break; + } + } + + private appendStateInstructions(state: TaskState | undefined) { + if (state !== 'auth-required') { + return; + } + + // Prevent duplicate instructions if multiple chunks report auth-required + if (!this.messageLog.includes(AUTH_REQUIRED_MSG)) { + this.messageLog.push(AUTH_REQUIRED_MSG); + } + } + + private pushMessage(message: Message | undefined) { + if (!message) return; + if (message.role === 'user') return; // Skip user messages reflected by server + const text = extractPartsText(message.parts, ''); + if (text && this.messageLog.at(-1) !== text) { + this.messageLog.push(text); + } + } + + /** + * Returns an array of activity items representing the current reassembled state. + */ + toActivityItems(): SubagentActivityItem[] { + const isAuthRequired = this.messageLog.includes(AUTH_REQUIRED_MSG); + const items: SubagentActivityItem[] = []; + + if (isAuthRequired) { + items.push({ + id: 'auth-required', + type: 'thought', + content: AUTH_REQUIRED_MSG, + status: SubagentState.RUNNING, + }); + } + + this.messageLog.forEach((msg, index) => { + items.push({ + id: `msg-${index}`, + type: 'thought', + content: msg.trim(), + status: SubagentState.COMPLETED, + }); + }); + + if (items.length === 0 && !isAuthRequired) { + items.push({ + id: 'pending', + type: 'thought', + content: 'Working...', + status: SubagentState.RUNNING, + }); + } + + return items; + } + + /** + * Returns a human-readable string representation of the current reassembled state. + */ + toString(): string { + const joinedMessages = this.messageLog.join(''); + + const artifactsOutput = Array.from(this.artifacts.keys()) + .map((id) => { + const chunks = this.artifactChunks.get(id); + const artifact = this.artifacts.get(id); + if (!chunks || !artifact) return ''; + const content = chunks.join(''); + const header = artifact.name + ? `Artifact (${artifact.name}):` + : 'Artifact:'; + return `${header}\n${content}`; + }) + .filter(Boolean) + .join('\n\n'); + + if (joinedMessages && artifactsOutput) { + return `${joinedMessages}\n\n${artifactsOutput}`; + } + return joinedMessages || artifactsOutput; + } +} + +/** + * Extracts a human-readable text representation from a Message object. + * Handles Text, Data (JSON), and File parts. + */ +export function extractMessageText(message: Message | undefined): string { + if (!message || !message.parts || !Array.isArray(message.parts)) { + return ''; + } + + return extractPartsText(message.parts, '\n'); +} + +/** + * Extracts text from an array of parts, joining them with the specified separator. + */ +function extractPartsText( + parts: Part[] | undefined, + separator: string, +): string { + if (!parts || parts.length === 0) { + return ''; + } + return parts + .map((p) => extractPartText(p)) + .filter(Boolean) + .join(separator); +} + +/** + * Extracts text from a single Part. + */ +function extractPartText(part: Part): string { + if (isTextPart(part)) { + return part.text; + } + + if (isDataPart(part)) { + return `Data: ${JSON.stringify(part.data)}`; + } + + if (isFilePart(part)) { + const fileData = part.file; + if (fileData.name) { + return `File: ${fileData.name}`; + } + if ('uri' in fileData && fileData.uri) { + return `File: ${fileData.uri}`; + } + return `File: [binary/unnamed]`; + } + + return ''; +} + +/** + * Normalizes proto field name aliases that the SDK doesn't handle yet. + * The A2A proto spec uses `supported_interfaces` and `protocol_binding`, + * while the SDK expects `additionalInterfaces` and `transport`. + * TODO: Remove once @a2a-js/sdk handles these aliases natively. + */ +export function normalizeAgentCard(card: unknown): AgentCard { + if (!isObject(card)) { + throw new Error('Agent card is missing.'); + } + + // Shallow-copy to avoid mutating the SDK's cached object. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const result = { ...card } as unknown as AgentCard; + + // Map supportedInterfaces β†’ additionalInterfaces if needed + if (!result.additionalInterfaces) { + const raw = card; + if (Array.isArray(raw['supportedInterfaces'])) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + result.additionalInterfaces = raw[ + 'supportedInterfaces' + ] as AgentInterface[]; + } + } + + // Map protocolBinding β†’ transport on each interface + for (const intf of result.additionalInterfaces ?? []) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const raw = intf as unknown as Record; + const binding = raw['protocolBinding']; + + if (!intf.transport && typeof binding === 'string') { + intf.transport = binding; + } + } + + return result; +} + +/** + * Extracts contextId and taskId from a Message, Task, or Update response. + * Follows the pattern from the A2A CLI sample to maintain conversational continuity. + */ +export function extractIdsFromResponse(result: SendMessageResult): { + contextId?: string; + taskId?: string; + clearTaskId?: boolean; +} { + let contextId: string | undefined; + let taskId: string | undefined; + let clearTaskId = false; + + if (!('kind' in result)) return { contextId, taskId, clearTaskId }; + + switch (result.kind) { + case 'message': + case 'artifact-update': + taskId = result.taskId; + contextId = result.contextId; + break; + + case 'task': + taskId = result.id; + contextId = result.contextId; + if (isTerminalState(result.status?.state)) { + clearTaskId = true; + } + break; + + case 'status-update': + taskId = result.taskId; + contextId = result.contextId; + if (isTerminalState(result.status?.state)) { + clearTaskId = true; + } + break; + default: + // Handle other kind values if any + break; + } + + return { contextId, taskId, clearTaskId }; +} + +// Type Guards + +function isTextPart(part: Part): part is TextPart { + return part.kind === 'text'; +} + +function isDataPart(part: Part): part is DataPart { + return part.kind === 'data'; +} + +function isFilePart(part: Part): part is FilePart { + return part.kind === 'file'; +} + +/** + * Returns true if the given state is a terminal state for a task. + */ +export function isTerminalState(state: TaskState | undefined): boolean { + return ( + state === 'completed' || + state === 'failed' || + state === 'canceled' || + state === 'rejected' + ); +} + +/** + * Type guard to check if a value is a non-array object. + */ +function isObject(val: unknown): val is Record { + return typeof val === 'object' && val !== null && !Array.isArray(val); +} diff --git a/packages/core/src/agents/acknowledgedAgents.test.ts b/packages/core/src/agents/acknowledgedAgents.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6e45360db69cfc4447953254c294e34a79cf081 --- /dev/null +++ b/packages/core/src/agents/acknowledgedAgents.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AcknowledgedAgentsService } from './acknowledgedAgents.js'; +import { Storage } from '../config/storage.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('AcknowledgedAgentsService', () => { + let tempDir: string; + let originalGeminiCliHome: string | undefined; + + beforeEach(async () => { + // Create a unique temp directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + + // Override GEMINI_CLI_HOME to point to the temp directory + originalGeminiCliHome = process.env['GEMINI_CLI_HOME']; + process.env['GEMINI_CLI_HOME'] = tempDir; + }); + + afterEach(async () => { + // Restore environment variable + if (originalGeminiCliHome) { + process.env['GEMINI_CLI_HOME'] = originalGeminiCliHome; + } else { + delete process.env['GEMINI_CLI_HOME']; + } + + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should acknowledge an agent and save to disk', async () => { + const service = new AcknowledgedAgentsService(); + const ackPath = Storage.getAcknowledgedAgentsPath(); + + await service.acknowledge('/project', 'AgentA', 'hash1'); + + // Verify file exists and content + const content = await fs.readFile(ackPath, 'utf-8'); + expect(content).toContain('"AgentA": "hash1"'); + }); + + it('should return true for acknowledged agent', async () => { + const service = new AcknowledgedAgentsService(); + + await service.acknowledge('/project', 'AgentA', 'hash1'); + + expect(await service.isAcknowledged('/project', 'AgentA', 'hash1')).toBe( + true, + ); + expect(await service.isAcknowledged('/project', 'AgentA', 'hash2')).toBe( + false, + ); + expect(await service.isAcknowledged('/project', 'AgentB', 'hash1')).toBe( + false, + ); + }); + + it('should load acknowledged agents from disk', async () => { + const ackPath = Storage.getAcknowledgedAgentsPath(); + const data = { + '/project': { + AgentLoaded: 'hashLoaded', + }, + }; + + // Ensure directory exists + await fs.mkdir(path.dirname(ackPath), { recursive: true }); + await fs.writeFile(ackPath, JSON.stringify(data), 'utf-8'); + + const service = new AcknowledgedAgentsService(); + + expect( + await service.isAcknowledged('/project', 'AgentLoaded', 'hashLoaded'), + ).toBe(true); + }); + + it('should handle load errors gracefully', async () => { + // Create a directory where the file should be to cause a read error (EISDIR) + const ackPath = Storage.getAcknowledgedAgentsPath(); + await fs.mkdir(ackPath, { recursive: true }); + + const service = new AcknowledgedAgentsService(); + + // Should not throw, and treated as empty + expect(await service.isAcknowledged('/project', 'Agent', 'hash')).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/agents/acknowledgedAgents.ts b/packages/core/src/agents/acknowledgedAgents.ts new file mode 100644 index 0000000000000000000000000000000000000000..98c90afb96c2dfa13eb8304309b814410923d7ba --- /dev/null +++ b/packages/core/src/agents/acknowledgedAgents.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { getErrorMessage, isNodeError } from '../utils/errors.js'; + +export interface AcknowledgedAgentsMap { + // Project Path -> Agent Name -> Agent Hash + [projectPath: string]: { + [agentName: string]: string; + }; +} + +export class AcknowledgedAgentsService { + private acknowledgedAgents: AcknowledgedAgentsMap = {}; + private loaded = false; + + async load(): Promise { + if (this.loaded) return; + + const filePath = Storage.getAcknowledgedAgentsPath(); + try { + const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.acknowledgedAgents = JSON.parse(content); + } catch (error: unknown) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + debugLogger.error( + 'Failed to load acknowledged agents:', + getErrorMessage(error), + ); + } + // If file doesn't exist or there's a parsing error, fallback to empty + this.acknowledgedAgents = {}; + } + this.loaded = true; + } + + async save(): Promise { + const filePath = Storage.getAcknowledgedAgentsPath(); + try { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify(this.acknowledgedAgents, null, 2), + 'utf-8', + ); + } catch (error) { + debugLogger.error( + 'Failed to save acknowledged agents:', + getErrorMessage(error), + ); + } + } + + async isAcknowledged( + projectPath: string, + agentName: string, + hash: string, + ): Promise { + await this.load(); + const projectAgents = this.acknowledgedAgents[projectPath]; + if (!projectAgents) return false; + return projectAgents[agentName] === hash; + } + + async acknowledge( + projectPath: string, + agentName: string, + hash: string, + ): Promise { + await this.load(); + if (!this.acknowledgedAgents[projectPath]) { + this.acknowledgedAgents[projectPath] = {}; + } + this.acknowledgedAgents[projectPath][agentName] = hash; + await this.save(); + } +} diff --git a/packages/core/src/agents/agent-scheduler.test.ts b/packages/core/src/agents/agent-scheduler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ac15f181e0a61c825ef697ab68b214a32998fda --- /dev/null +++ b/packages/core/src/agents/agent-scheduler.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { scheduleAgentTools } from './agent-scheduler.js'; +import { Scheduler } from '../scheduler/scheduler.js'; +import type { Config } from '../config/config.js'; +import type { ToolRegistry } from '../tools/tool-registry.js'; +import type { ToolCallRequestInfo } from '../scheduler/types.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; + +vi.mock('../scheduler/scheduler.js', () => ({ + Scheduler: vi.fn().mockImplementation(() => ({ + schedule: vi.fn().mockResolvedValue([{ status: 'success' }]), + dispose: vi.fn(), + })), +})); + +describe('agent-scheduler', () => { + let mockToolRegistry: Mocked; + let mockConfig: Mocked; + let mockMessageBus: Mocked; + + beforeEach(() => { + vi.mocked(Scheduler).mockClear(); + mockMessageBus = {} as Mocked; + mockToolRegistry = { + getTool: vi.fn(), + messageBus: mockMessageBus, + } as unknown as Mocked; + mockConfig = { + messageBus: mockMessageBus, + toolRegistry: mockToolRegistry, + } as unknown as Mocked; + (mockConfig as unknown as { messageBus: MessageBus }).messageBus = + mockMessageBus; + (mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = + mockToolRegistry; + }); + + it('should create a scheduler with agent-specific config', async () => { + const mockConfig = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), + messageBus: mockMessageBus, + toolRegistry: mockToolRegistry, + } as unknown as Mocked; + + const requests: ToolCallRequestInfo[] = [ + { + callId: 'call-1', + name: 'test-tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + ]; + + const options = { + schedulerId: 'subagent-1', + parentCallId: 'parent-1', + toolRegistry: mockToolRegistry as unknown as ToolRegistry, + signal: new AbortController().signal, + }; + + const results = await scheduleAgentTools( + mockConfig as unknown as Config, + requests, + options, + ); + + expect(results).toEqual([{ status: 'success' }]); + expect(Scheduler).toHaveBeenCalledWith( + expect.objectContaining({ + schedulerId: 'subagent-1', + parentCallId: 'parent-1', + messageBus: mockMessageBus, + }), + ); + + // Verify that the scheduler's context has the overridden tool registry + const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].context; + expect(schedulerConfig.toolRegistry).toBe(mockToolRegistry); + }); + + it('should override toolRegistry getter from prototype chain', async () => { + const mainRegistry = { _id: 'main' } as unknown as Mocked; + const agentRegistry = { + _id: 'agent', + messageBus: mockMessageBus, + } as unknown as Mocked; + + const config = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), + messageBus: mockMessageBus, + } as unknown as Mocked; + Object.defineProperty(config, 'toolRegistry', { + get: () => mainRegistry, + configurable: true, + }); + + await scheduleAgentTools( + config as unknown as Config, + [ + { + callId: 'c1', + name: 'new_page', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + ], + { + schedulerId: 'browser-1', + toolRegistry: agentRegistry as unknown as ToolRegistry, + signal: new AbortController().signal, + }, + ); + + const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].context; + expect(schedulerConfig.toolRegistry).toBe(agentRegistry); + expect(schedulerConfig.toolRegistry).not.toBe(mainRegistry); + }); + + it('should dispose the scheduler after schedule completes', async () => { + const mockConfig = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), + messageBus: mockMessageBus, + toolRegistry: mockToolRegistry, + } as unknown as Mocked; + + const options = { + schedulerId: 'subagent-1', + toolRegistry: mockToolRegistry as unknown as ToolRegistry, + signal: new AbortController().signal, + }; + + await scheduleAgentTools(mockConfig as unknown as Config, [], options); + + const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value; + expect(schedulerInstance.dispose).toHaveBeenCalledOnce(); + }); + + it('should dispose the scheduler even when schedule throws', async () => { + const scheduleError = new Error('schedule failed'); + vi.mocked(Scheduler).mockImplementationOnce( + () => + ({ + schedule: vi.fn().mockRejectedValue(scheduleError), + dispose: vi.fn(), + }) as unknown as Scheduler, + ); + + const mockConfig = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), + messageBus: mockMessageBus, + toolRegistry: mockToolRegistry, + } as unknown as Mocked; + + const options = { + schedulerId: 'subagent-1', + toolRegistry: mockToolRegistry as unknown as ToolRegistry, + signal: new AbortController().signal, + }; + + await expect( + scheduleAgentTools(mockConfig as unknown as Config, [], options), + ).rejects.toThrow('schedule failed'); + + const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value; + expect(schedulerInstance.dispose).toHaveBeenCalledOnce(); + }); + + it('should create an AgentLoopContext that has a defined .config property', async () => { + const mockConfig = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), + messageBus: mockMessageBus, + toolRegistry: mockToolRegistry, + promptId: 'test-prompt', + } as unknown as Mocked; + + const options = { + schedulerId: 'subagent-1', + toolRegistry: mockToolRegistry as unknown as ToolRegistry, + signal: new AbortController().signal, + }; + + await scheduleAgentTools(mockConfig as unknown as Config, [], options); + + const schedulerContext = vi.mocked(Scheduler).mock.calls[0][0].context; + expect(schedulerContext.config).toBeDefined(); + expect(schedulerContext.config.promptId).toBe('test-prompt'); + expect(schedulerContext.toolRegistry).toBe(mockToolRegistry); + }); +}); diff --git a/packages/core/src/agents/agent-scheduler.ts b/packages/core/src/agents/agent-scheduler.ts new file mode 100644 index 0000000000000000000000000000000000000000..09b32980a9c3f2b2dd2e66354ff846339d6fff56 --- /dev/null +++ b/packages/core/src/agents/agent-scheduler.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { Scheduler } from '../scheduler/scheduler.js'; +import type { + ToolCallRequestInfo, + CompletedToolCall, +} from '../scheduler/types.js'; +import type { ToolRegistry } from '../tools/tool-registry.js'; +import type { PromptRegistry } from '../prompts/prompt-registry.js'; +import type { ResourceRegistry } from '../resources/resource-registry.js'; +import type { EditorType } from '../utils/editor.js'; + +/** + * Options for scheduling agent tools. + */ +export interface AgentSchedulingOptions { + /** The unique ID for this agent's scheduler. */ + schedulerId: string; + /** The name of the subagent. */ + subagent?: string; + /** The ID of the tool call that invoked this agent. */ + parentCallId?: string; + /** The tool registry specific to this agent. */ + toolRegistry: ToolRegistry; + /** The prompt registry specific to this agent. */ + promptRegistry?: PromptRegistry; + /** The resource registry specific to this agent. */ + resourceRegistry?: ResourceRegistry; + /** AbortSignal for cancellation. */ + signal: AbortSignal; + /** Optional function to get the preferred editor for tool modifications. */ + getPreferredEditor?: () => EditorType | undefined; + /** Optional function to be notified when the scheduler is waiting for user confirmation. */ + onWaitingForConfirmation?: (waiting: boolean) => void; +} + +/** + * Schedules a batch of tool calls for an agent using the new event-driven Scheduler. + * + * @param config The global runtime configuration. + * @param requests The list of tool call requests from the agent. + * @param options Scheduling options including registry and IDs. + * @returns A promise that resolves to the completed tool calls. + */ +export async function scheduleAgentTools( + config: Config, + requests: ToolCallRequestInfo[], + options: AgentSchedulingOptions, +): Promise { + const { + schedulerId, + subagent, + parentCallId, + toolRegistry, + promptRegistry, + resourceRegistry, + signal, + getPreferredEditor, + onWaitingForConfirmation, + } = options; + + const schedulerContext = { + config, + promptId: config.promptId, + toolRegistry, + promptRegistry: promptRegistry ?? config.getPromptRegistry(), + resourceRegistry: resourceRegistry ?? config.getResourceRegistry(), + messageBus: toolRegistry.messageBus, + geminiClient: config.geminiClient, + sandboxManager: config.sandboxManager, + }; + + const scheduler = new Scheduler({ + context: schedulerContext, + messageBus: toolRegistry.messageBus, + getPreferredEditor: getPreferredEditor ?? (() => undefined), + schedulerId, + subagent, + parentCallId, + onWaitingForConfirmation, + }); + + try { + return await scheduler.schedule(requests, signal); + } finally { + scheduler.dispose(); + } +} diff --git a/packages/core/src/agents/agent-tool.test.ts b/packages/core/src/agents/agent-tool.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..86636f095d5b002d9bb7fa5f14e028420af9c18c --- /dev/null +++ b/packages/core/src/agents/agent-tool.test.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AgentTool } from './agent-tool.js'; +import { makeFakeConfig } from '../test-utils/config.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { LocalSubagentInvocation } from './local-invocation.js'; +import { RemoteAgentInvocation } from './remote-invocation.js'; +import { LocalSessionInvocation } from './local-session-invocation.js'; +import { RemoteSessionInvocation } from './remote-session-invocation.js'; +import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js'; +import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js'; +import { AgentRegistry } from './registry.js'; +import type { LocalAgentDefinition, RemoteAgentDefinition } from './types.js'; + +vi.mock('./local-invocation.js'); +vi.mock('./remote-invocation.js'); +vi.mock('./local-session-invocation.js'); +vi.mock('./remote-session-invocation.js'); +vi.mock('./browser/browserAgentInvocation.js'); + +describe('AgentTool', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; + let tool: AgentTool; + + const testLocalDefinition: LocalAgentDefinition = { + kind: 'local', + name: 'TestLocalAgent', + description: 'A local test agent.', + inputConfig: { + inputSchema: { + type: 'object', + properties: { objective: { type: 'string' } }, + }, + }, + modelConfig: { model: 'test', generateContentConfig: {} }, + runConfig: { maxTimeMinutes: 1 }, + promptConfig: { systemPrompt: 'test' }, + }; + + const testRemoteDefinition: RemoteAgentDefinition = { + kind: 'remote', + name: 'TestRemoteAgent', + description: 'A remote test agent.', + inputConfig: { + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + }, + }, + agentCardUrl: 'http://example.com/agent', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = makeFakeConfig(); + mockMessageBus = createMockMessageBus(); + tool = new AgentTool(mockConfig, mockMessageBus); + + // Mock AgentRegistry + const registry = new AgentRegistry(mockConfig); + vi.spyOn(mockConfig, 'getAgentRegistry').mockReturnValue(registry); + + vi.spyOn(registry, 'getDefinition').mockImplementation((name: string) => { + if (name === 'TestLocalAgent') return testLocalDefinition; + if (name === 'TestRemoteAgent') return testRemoteDefinition; + if (name === BROWSER_AGENT_NAME) { + return { + kind: 'remote', + name: BROWSER_AGENT_NAME, + displayName: 'Browser Agent', + description: 'Browser Agent Description', + inputConfig: { + inputSchema: { + type: 'object', + properties: { task: { type: 'string' } }, + }, + }, + agentCardUrl: 'http://example.com', + }; + } + return undefined; + }); + }); + + it('should map prompt to objective for local agent', async () => { + const params = { agent_name: 'TestLocalAgent', prompt: 'Do something' }; + const invocation = tool['createInvocation'](params, mockMessageBus); + + // Trigger deferred instantiation + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(LocalSubagentInvocation).toHaveBeenCalledWith( + testLocalDefinition, + mockConfig, + { objective: 'Do something' }, + mockMessageBus, + ); + }); + + it('should map prompt to query for remote agent', async () => { + const params = { + agent_name: 'TestRemoteAgent', + prompt: 'Search something', + }; + const invocation = tool['createInvocation'](params, mockMessageBus); + + // Trigger deferred instantiation + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(RemoteAgentInvocation).toHaveBeenCalledWith( + testRemoteDefinition, + mockConfig, + { query: 'Search something' }, + mockMessageBus, + ); + }); + + it('should throw error for unknown subagent', () => { + const params = { agent_name: 'UnknownAgent', prompt: 'Hello' }; + expect(() => { + tool['createInvocation'](params, mockMessageBus); + }).toThrow("Subagent 'UnknownAgent' not found."); + }); + + it('should map prompt to task and use BrowserAgentInvocation for browser agent', async () => { + const params = { agent_name: BROWSER_AGENT_NAME, prompt: 'Open page' }; + const invocation = tool['createInvocation'](params, mockMessageBus); + + // Trigger deferred instantiation + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(BrowserAgentInvocation).toHaveBeenCalledWith( + mockConfig, + { task: 'Open page' }, + mockMessageBus, + 'invoke_agent', + 'Invoke Browser Agent', + ); + }); + + describe('agentSessionSubagentEnabled feature flag', () => { + it('should use LocalSessionInvocation when flag is enabled for local agent', async () => { + vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue( + true, + ); + tool = new AgentTool(mockConfig, mockMessageBus); + + const params = { + agent_name: 'TestLocalAgent', + prompt: 'Do something', + }; + const invocation = tool['createInvocation'](params, mockMessageBus); + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(LocalSessionInvocation).toHaveBeenCalledWith( + testLocalDefinition, + mockConfig, + { objective: 'Do something' }, + mockMessageBus, + undefined, + ); + expect(LocalSubagentInvocation).not.toHaveBeenCalled(); + }); + + it('should use RemoteSessionInvocation when flag is enabled for remote agent', async () => { + vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue( + true, + ); + tool = new AgentTool(mockConfig, mockMessageBus); + + const params = { + agent_name: 'TestRemoteAgent', + prompt: 'Search something', + }; + const invocation = tool['createInvocation'](params, mockMessageBus); + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(RemoteSessionInvocation).toHaveBeenCalledWith( + testRemoteDefinition, + mockConfig, + { query: 'Search something' }, + mockMessageBus, + undefined, + ); + expect(RemoteAgentInvocation).not.toHaveBeenCalled(); + }); + + it('should use legacy invocations when flag is disabled (default)', async () => { + vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue( + false, + ); + tool = new AgentTool(mockConfig, mockMessageBus); + + const localParams = { + agent_name: 'TestLocalAgent', + prompt: 'Do something', + }; + const localInv = tool['createInvocation'](localParams, mockMessageBus); + await localInv.shouldConfirmExecute(new AbortController().signal); + + expect(LocalSubagentInvocation).toHaveBeenCalled(); + expect(LocalSessionInvocation).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + + const remoteParams = { + agent_name: 'TestRemoteAgent', + prompt: 'Search', + }; + const remoteInv = tool['createInvocation'](remoteParams, mockMessageBus); + await remoteInv.shouldConfirmExecute(new AbortController().signal); + + expect(RemoteAgentInvocation).toHaveBeenCalled(); + expect(RemoteSessionInvocation).not.toHaveBeenCalled(); + }); + + it('should thread onAgentEvent to session invocations', async () => { + vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue( + true, + ); + const onEvent = vi.fn(); + tool = new AgentTool(mockConfig, mockMessageBus, onEvent); + + const params = { + agent_name: 'TestLocalAgent', + prompt: 'Do something', + }; + const invocation = tool['createInvocation'](params, mockMessageBus); + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(LocalSessionInvocation).toHaveBeenCalledWith( + testLocalDefinition, + mockConfig, + { objective: 'Do something' }, + mockMessageBus, + { onAgentEvent: onEvent }, + ); + }); + + it('should always use BrowserAgentInvocation for browser agent regardless of flag', async () => { + vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue( + true, + ); + tool = new AgentTool(mockConfig, mockMessageBus); + + const params = { + agent_name: BROWSER_AGENT_NAME, + prompt: 'Open page', + }; + const invocation = tool['createInvocation'](params, mockMessageBus); + await invocation.shouldConfirmExecute(new AbortController().signal); + + expect(BrowserAgentInvocation).toHaveBeenCalled(); + expect(LocalSessionInvocation).not.toHaveBeenCalled(); + expect(RemoteSessionInvocation).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/agents/agent-tool.ts b/packages/core/src/agents/agent-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bbe654fd42db685063046bc9d517244531aa0fa --- /dev/null +++ b/packages/core/src/agents/agent-tool.ts @@ -0,0 +1,282 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + Kind, + type ToolInvocation, + type ToolResult, + BaseToolInvocation, + type ToolCallConfirmationDetails, + type ExecuteOptions, +} from '../tools/tools.js'; +import { type AgentLoopContext } from '../config/agent-loop-context.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { AgentDefinition, AgentInputs } from './types.js'; +import { LocalSubagentInvocation } from './local-invocation.js'; +import { RemoteAgentInvocation } from './remote-invocation.js'; +import { LocalSessionInvocation } from './local-session-invocation.js'; +import { RemoteSessionInvocation } from './remote-session-invocation.js'; +import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js'; +import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js'; +import type { AgentEvent } from '../agent/types.js'; +import { formatUserHintsForModel } from '../utils/fastAckHelper.js'; +import { isRecord } from '../utils/markdownUtils.js'; +import { runInDevTraceSpan } from '../telemetry/trace.js'; +import { + GeminiCliOperation, + GEN_AI_AGENT_DESCRIPTION, + GEN_AI_AGENT_NAME, +} from '../telemetry/constants.js'; +import { AGENT_TOOL_NAME } from '../tools/tool-names.js'; + +/** + * A unified tool for invoking subagents. + * + * Handles looking up the subagent, validating its eligibility, + * mapping the general 'prompt' parameter to the agent's specific schema, + * and delegating execution. + */ +export class AgentTool extends BaseDeclarativeTool< + { agent_name: string; prompt: string }, + ToolResult +> { + static readonly Name = AGENT_TOOL_NAME; + + constructor( + private readonly context: AgentLoopContext, + messageBus: MessageBus, + private readonly onAgentEvent?: (event: AgentEvent) => void, + ) { + super( + AGENT_TOOL_NAME, + 'Invoke Subagent', + 'Invoke a subagent to perform a specific task or investigation.', + Kind.Agent, + { + type: 'object', + properties: { + agent_name: { + type: 'string', + description: 'Name of the subagent to invoke', + }, + prompt: { + type: 'string', + description: + 'The COMPLETE query to send the subagent. MUST be comprehensive and detailed. Include all context, background, questions, and expected output format. Do NOT send brief or incomplete instructions.', + }, + }, + required: ['agent_name', 'prompt'], + }, + messageBus, + /* isOutputMarkdown */ true, + /* canUpdateOutput */ true, + ); + } + + protected createInvocation( + params: { agent_name: string; prompt: string }, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ): ToolInvocation<{ agent_name: string; prompt: string }, ToolResult> { + const registry = this.context.config.getAgentRegistry(); + const definition = registry.getDefinition(params.agent_name); + + if (!definition) { + throw new Error(`Subagent '${params.agent_name}' not found.`); + } + + // Smart Parameter Mapping + const mappedInputs = this.mapParams( + params.prompt, + definition.inputConfig.inputSchema, + ); + + return new DelegateInvocation( + params, + mappedInputs, + messageBus, + definition, + this.context, + _toolName, + _toolDisplayName, + this.onAgentEvent, + ); + } + + private mapParams(prompt: string, schema: unknown): AgentInputs { + const schemaObj: unknown = schema; + if (!isRecord(schemaObj)) { + return { prompt }; + } + const properties = schemaObj['properties']; + if (isRecord(properties)) { + const keys = Object.keys(properties); + if (keys.length === 1) { + return { [keys[0]]: prompt }; + } + } + return { prompt }; + } +} + +class DelegateInvocation extends BaseToolInvocation< + { agent_name: string; prompt: string }, + ToolResult +> { + private readonly startIndex: number; + + constructor( + params: { agent_name: string; prompt: string }, + private readonly mappedInputs: AgentInputs, + messageBus: MessageBus, + private readonly definition: AgentDefinition, + private readonly context: AgentLoopContext, + _toolName?: string, + _toolDisplayName?: string, + private readonly onAgentEvent?: (event: AgentEvent) => void, + ) { + super( + params, + messageBus, + _toolName ?? AGENT_TOOL_NAME, + _toolDisplayName ?? `Invoke ${definition.displayName ?? definition.name}`, + ); + this.startIndex = context.config.injectionService.getLatestInjectionIndex(); + } + + getDescription(): string { + return `Delegating to agent '${this.definition.name}'`; + } + + private buildChildInvocation( + agentArgs: AgentInputs, + ): ToolInvocation { + if (this.definition.name === BROWSER_AGENT_NAME) { + return new BrowserAgentInvocation( + this.context, + agentArgs, + this.messageBus, + this._toolName, + this._toolDisplayName, + ); + } + + const useSession = this.context.config.isAgentSessionSubagentEnabled(); + const options = this.onAgentEvent + ? { onAgentEvent: this.onAgentEvent } + : undefined; + + if (this.definition.kind === 'remote') { + if (useSession) { + return new RemoteSessionInvocation( + this.definition, + this.context, + agentArgs, + this.messageBus, + options, + ); + } + return new RemoteAgentInvocation( + this.definition, + this.context, + agentArgs, + this.messageBus, + ); + } else { + if (useSession) { + return new LocalSessionInvocation( + this.definition, + this.context, + agentArgs, + this.messageBus, + options, + ); + } + return new LocalSubagentInvocation( + this.definition, + this.context, + agentArgs, + this.messageBus, + ); + } + } + + override async shouldConfirmExecute( + abortSignal: AbortSignal, + ): Promise { + const hintedParams = this.withUserHints(this.mappedInputs); + const invocation = this.buildChildInvocation(hintedParams); + return invocation.shouldConfirmExecute(abortSignal); + } + + async execute(options: ExecuteOptions): Promise { + const { abortSignal: signal, updateOutput } = options; + const hintedParams = this.withUserHints(this.mappedInputs); + const invocation = this.buildChildInvocation(hintedParams); + + return runInDevTraceSpan( + { + operation: GeminiCliOperation.AgentCall, + logPrompts: this.context.config.getTelemetryLogPromptsEnabled(), + tracesEnabled: this.context.config.getTelemetryTracesEnabled(), + sessionId: this.context.config.getSessionId(), + attributes: { + [GEN_AI_AGENT_NAME]: this.definition.name, + [GEN_AI_AGENT_DESCRIPTION]: this.definition.description, + }, + }, + async ({ metadata }) => { + metadata.input = this.params; + const result = await invocation.execute({ + abortSignal: signal, + updateOutput, + }); + metadata.output = result; + return result; + }, + ); + } + + private withUserHints(agentArgs: AgentInputs): AgentInputs { + if (this.definition.kind !== 'remote') { + return agentArgs; + } + + const userHints = this.context.config.injectionService.getInjectionsAfter( + this.startIndex, + 'user_steering', + ); + const formattedHints = formatUserHintsForModel(userHints); + if (!formattedHints) { + return agentArgs; + } + + // Find the primary key to append hints to + const schemaObj: unknown = this.definition.inputConfig.inputSchema; + if (!isRecord(schemaObj)) { + return agentArgs; + } + const properties = schemaObj['properties']; + if (isRecord(properties)) { + const keys = Object.keys(properties); + const primaryKey = keys.length === 1 ? keys[0] : 'prompt'; + + const value = agentArgs[primaryKey]; + if (typeof value !== 'string' || value.trim().length === 0) { + return agentArgs; + } + + return { + ...agentArgs, + [primaryKey]: `${formattedHints}\n\n${value}`, + }; + } + + return agentArgs; + } +} diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..01e4dbe8d32e0cdaf8a25baae09d054909a93a76 --- /dev/null +++ b/packages/core/src/agents/agentLoader.test.ts @@ -0,0 +1,1145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + parseAgentMarkdown, + markdownToAgentDefinition, + loadAgentsFromDirectory, + AgentLoadError, +} from './agentLoader.js'; +import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js'; +import { + DEFAULT_MAX_TIME_MINUTES, + DEFAULT_MAX_TURNS, + type LocalAgentDefinition, + type RemoteAgentDefinition, + getAgentCardLoadOptions, + getRemoteAgentTargetUrl, +} from './types.js'; + +describe('loader', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + async function writeAgentMarkdown(content: string, fileName = 'test.md') { + const filePath = path.join(tempDir, fileName); + await fs.writeFile(filePath, content); + return filePath; + } + + describe('parseAgentMarkdown', () => { + it('should parse a valid markdown agent file', async () => { + const filePath = await writeAgentMarkdown(`--- +name: test-agent-md +description: A markdown agent +--- +You are a markdown agent.`); + + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'test-agent-md', + description: 'A markdown agent', + kind: 'local', + system_prompt: 'You are a markdown agent.', + }); + }); + + it('should parse frontmatter with tools and model config', async () => { + const filePath = await writeAgentMarkdown(`--- +name: complex-agent +description: A complex markdown agent +tools: + - run_shell_command +model: gemini-pro +temperature: 0.7 +--- +System prompt content.`); + + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'complex-agent', + description: 'A complex markdown agent', + tools: ['run_shell_command'], + model: 'gemini-pro', + temperature: 0.7, + system_prompt: 'System prompt content.', + }); + }); + + it('should parse frontmatter with mcp_servers', async () => { + const filePath = await writeAgentMarkdown(`--- +name: mcp-agent +description: An agent with MCP servers +mcp_servers: + test-server: + command: node + args: [server.js] + include_tools: [tool1, tool2] +--- +System prompt content.`); + + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'mcp-agent', + description: 'An agent with MCP servers', + mcp_servers: { + 'test-server': { + command: 'node', + args: ['server.js'], + include_tools: ['tool1', 'tool2'], + }, + }, + }); + }); + + it('should throw AgentLoadError if frontmatter is missing', async () => { + const filePath = await writeAgentMarkdown(`Just some markdown content.`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + AgentLoadError, + ); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + 'Missing mandatory YAML frontmatter', + ); + }); + + it('should throw AgentLoadError if frontmatter is invalid YAML', async () => { + const filePath = await writeAgentMarkdown(`--- +name: [invalid yaml +--- +Body`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + AgentLoadError, + ); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + 'YAML frontmatter parsing failed', + ); + }); + + it('should throw AgentLoadError if validation fails (missing required field)', async () => { + const filePath = await writeAgentMarkdown(`--- +name: test-agent +# missing description +--- +Body`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Validation failed/, + ); + }); + + it('should parse a valid remote agent markdown file', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: remote-agent +description: A remote agent +agent_card_url: https://example.com/card +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'remote-agent', + description: 'A remote agent', + agent_card_url: 'https://example.com/card', + }); + }); + + it('should infer remote agent kind from agent_card_url', async () => { + const filePath = await writeAgentMarkdown(`--- +name: inferred-remote +description: Inferred +agent_card_url: https://example.com/inferred +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'inferred-remote', + description: 'Inferred', + agent_card_url: 'https://example.com/inferred', + }); + }); + + it('should parse a remote agent with no body', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: no-body-remote +agent_card_url: https://example.com/card +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'no-body-remote', + agent_card_url: 'https://example.com/card', + }); + }); + + it('should parse multiple remote agents in a list', async () => { + const filePath = await writeAgentMarkdown(`--- +- kind: remote + name: remote-1 + agent_card_url: https://example.com/1 +- kind: remote + name: remote-2 + agent_card_url: https://example.com/2 +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'remote-1', + agent_card_url: 'https://example.com/1', + }); + expect(result[1]).toEqual({ + kind: 'remote', + name: 'remote-2', + agent_card_url: 'https://example.com/2', + }); + }); + + it('should parse frontmatter without a trailing newline', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: no-trailing-newline +agent_card_url: https://example.com/card +---`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'no-trailing-newline', + agent_card_url: 'https://example.com/card', + }); + }); + + it('should parse a remote agent with agent_card_json', async () => { + const cardJson = JSON.stringify({ + name: 'json-agent', + url: 'https://example.com/agent', + version: '1.0', + }); + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: json-remote +description: A JSON-based remote agent +agent_card_json: '${cardJson}' +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'json-remote', + description: 'A JSON-based remote agent', + agent_card_json: cardJson, + }); + // Should NOT have agent_card_url + expect(result[0]).not.toHaveProperty('agent_card_url'); + }); + + it('should reject agent_card_json that is not valid JSON', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-json-remote +agent_card_json: "not valid json {{" +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /agent_card_json must be valid JSON/, + ); + }); + + it('should reject a remote agent with both agent_card_url and agent_card_json', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: both-fields +agent_card_url: https://example.com/card +agent_card_json: '{"name":"test"}' +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Validation failed/, + ); + }); + + it('should infer remote kind from agent_card_json', async () => { + const cardJson = JSON.stringify({ + name: 'test', + url: 'https://example.com', + }); + const filePath = await writeAgentMarkdown(`--- +name: inferred-json-remote +agent_card_json: '${cardJson}' +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'inferred-json-remote', + agent_card_json: cardJson, + }); + }); + + it('should throw AgentLoadError if agent name is not a valid slug', async () => { + const filePath = await writeAgentMarkdown(`--- +name: Invalid Name With Spaces +description: Test +--- +Body`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Name must be a valid slug/, + ); + }); + + describe('error formatting and kind inference', () => { + it('should only show local agent errors when kind is inferred as local (via kind field)', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: local +name: invalid-local +# missing description +--- +Body`); + const error = await parseAgentMarkdown(filePath).catch((e) => e); + expect(error).toBeInstanceOf(AgentLoadError); + expect(error.message).toContain('Validation failed'); + expect(error.message).toContain('description: Required'); + expect(error.message).not.toContain('Remote Agent'); + }); + + it('should only show local agent errors when kind is inferred as local (via local-specific keys)', async () => { + const filePath = await writeAgentMarkdown(`--- +name: invalid-local +# missing description +tools: + - run_shell_command +--- +Body`); + const error = await parseAgentMarkdown(filePath).catch((e) => e); + expect(error).toBeInstanceOf(AgentLoadError); + expect(error.message).toContain('Validation failed'); + expect(error.message).toContain('description: Required'); + expect(error.message).not.toContain('Remote Agent'); + }); + + it('should only show remote agent errors when kind is inferred as remote (via kind field)', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-remote +# missing agent_card_url +--- +Body`); + const error = await parseAgentMarkdown(filePath).catch((e) => e); + expect(error).toBeInstanceOf(AgentLoadError); + expect(error.message).toContain('Validation failed'); + expect(error.message).toContain('agent_card_url: Required'); + expect(error.message).not.toContain('Local Agent'); + }); + + it('should only show remote agent errors when kind is inferred as remote (via remote-specific keys)', async () => { + const filePath = await writeAgentMarkdown(`--- +name: invalid-remote +auth: + type: apiKey + key: my_key +# missing agent_card_url +--- +Body`); + const error = await parseAgentMarkdown(filePath).catch((e) => e); + expect(error).toBeInstanceOf(AgentLoadError); + expect(error.message).toContain('Validation failed'); + expect(error.message).toContain('agent_card_url: Required'); + expect(error.message).not.toContain('Local Agent'); + }); + + it('should show errors for both types when kind cannot be inferred', async () => { + const filePath = await writeAgentMarkdown(`--- +name: invalid-unknown +# missing description and missing agent_card_url, no specific keys +--- +Body`); + const error = await parseAgentMarkdown(filePath).catch((e) => e); + expect(error).toBeInstanceOf(AgentLoadError); + expect(error.message).toContain('Validation failed'); + expect(error.message).toContain('(Local Agent)'); + expect(error.message).toContain('(Remote Agent)'); + expect(error.message).toContain('description: Required'); + expect(error.message).toContain('agent_card_url: Required'); + }); + + it('should format errors without a stray colon when the path is empty (e.g. strict object with unknown keys)', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: local +name: my-agent +description: test +unknown_field: true +--- +Body`); + const error = await parseAgentMarkdown(filePath).catch((e) => e); + expect(error).toBeInstanceOf(AgentLoadError); + expect(error.message).toContain( + "Unrecognized key(s) in object: 'unknown_field'", + ); + expect(error.message).not.toContain(': Unrecognized key(s)'); + expect(error.message).not.toContain('Required'); + }); + }); + }); + + describe('markdownToAgentDefinition', () => { + it('should convert valid Markdown DTO to AgentDefinition with defaults', () => { + const markdown = { + kind: 'local' as const, + name: 'test-agent', + description: 'A test agent', + system_prompt: 'You are a test agent.', + }; + + const result = markdownToAgentDefinition(markdown); + expect(result).toMatchObject({ + name: 'test-agent', + description: 'A test agent', + promptConfig: { + systemPrompt: 'You are a test agent.', + query: '${query}', + }, + modelConfig: { + model: 'inherit', + generateContentConfig: { + topP: 0.95, + }, + }, + runConfig: { + maxTimeMinutes: DEFAULT_MAX_TIME_MINUTES, + maxTurns: DEFAULT_MAX_TURNS, + }, + inputConfig: { + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task for the agent.', + }, + }, + required: [], + }, + }, + }); + }); + + it('should pass through model aliases', () => { + const markdown = { + kind: 'local' as const, + name: 'test-agent', + description: 'A test agent', + model: GEMINI_MODEL_ALIAS_PRO, + system_prompt: 'You are a test agent.', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.modelConfig.model).toBe(GEMINI_MODEL_ALIAS_PRO); + }); + + it('should convert mcp_servers in local agent', () => { + const markdown = { + kind: 'local' as const, + name: 'mcp-agent', + description: 'An agent with MCP servers', + mcp_servers: { + 'test-server': { + command: 'node', + args: ['server.js'], + include_tools: ['tool1'], + }, + }, + system_prompt: 'prompt', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.kind).toBe('local'); + expect(result.mcpServers).toBeDefined(); + expect(result.mcpServers!['test-server']).toMatchObject({ + command: 'node', + args: ['server.js'], + includeTools: ['tool1'], + }); + }); + + it('should convert mcp_servers with auth block in local agent (google-credentials)', () => { + const markdown = { + kind: 'local' as const, + name: 'spanner-test-agent', + description: 'An agent to test Spanner MCP with auth', + mcp_servers: { + spanner: { + url: 'https://spanner.googleapis.com/mcp', + type: 'http' as const, + auth: { + type: 'google-credentials' as const, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + timeout: 30000, + }, + }, + system_prompt: 'You are a Spanner test agent.', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.kind).toBe('local'); + expect(result.mcpServers).toBeDefined(); + expect(result.mcpServers!['spanner']).toMatchObject({ + url: 'https://spanner.googleapis.com/mcp', + type: 'http', + authProviderType: 'google_credentials', + oauth: { + enabled: true, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + timeout: 30000, + }); + }); + + it('should convert mcp_servers with auth block in local agent (oauth with full fields)', () => { + const markdown = { + kind: 'local' as const, + name: 'oauth-test-agent', + description: 'An agent to test OAuth MCP with full fields', + mcp_servers: { + 'test-server': { + url: 'https://api.example.com/mcp', + type: 'http' as const, + auth: { + type: 'oauth' as const, + client_id: 'my-client-id', + client_secret: 'my-client-secret', + scopes: ['read', 'write'], + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + issuer: 'https://auth.example.com', + audiences: ['audience1'], + redirect_uri: 'http://localhost:8080/callback', + token_param_name: 'access_token', + registration_url: 'https://auth.example.com/register', + }, + timeout: 30000, + }, + }, + system_prompt: 'You are a test agent.', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.kind).toBe('local'); + expect(result.mcpServers).toBeDefined(); + expect(result.mcpServers!['test-server']).toMatchObject({ + url: 'https://api.example.com/mcp', + type: 'http', + oauth: { + enabled: true, + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + scopes: ['read', 'write'], + authorizationUrl: 'https://auth.example.com/authorize', + tokenUrl: 'https://auth.example.com/token', + issuer: 'https://auth.example.com', + audiences: ['audience1'], + redirectUri: 'http://localhost:8080/callback', + tokenParamName: 'access_token', + registrationUrl: 'https://auth.example.com/register', + }, + timeout: 30000, + }); + }); + + it('should pass through unknown model names (e.g. auto)', () => { + const markdown = { + kind: 'local' as const, + name: 'test-agent', + description: 'A test agent', + model: 'auto', + system_prompt: 'You are a test agent.', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.modelConfig.model).toBe('auto'); + }); + + it('should convert remote agent definition', () => { + const markdown = { + kind: 'remote' as const, + name: 'remote-agent', + description: 'A remote agent', + agent_card_url: 'https://example.com/card', + }; + + const result = markdownToAgentDefinition(markdown); + expect(result).toEqual({ + kind: 'remote', + name: 'remote-agent', + description: 'A remote agent', + displayName: undefined, + agentCardUrl: 'https://example.com/card', + inputConfig: { + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task for the agent.', + }, + }, + required: [], + }, + }, + }); + }); + + it('should convert remote agent definition with agent_card_json', () => { + const cardJson = JSON.stringify({ + name: 'json-agent', + url: 'https://example.com/agent', + }); + const markdown = { + kind: 'remote' as const, + name: 'json-remote', + description: 'A JSON remote agent', + agent_card_json: cardJson, + }; + + const result = markdownToAgentDefinition( + markdown, + ) as RemoteAgentDefinition; + expect(result.kind).toBe('remote'); + expect(result.name).toBe('json-remote'); + expect(result.agentCardJson).toBe(cardJson); + expect(result.agentCardUrl).toBeUndefined(); + }); + + it('should throw for remote agent with neither agent_card_url nor agent_card_json', () => { + // Cast to bypass compile-time check β€” this tests the runtime guard + const markdown = { + kind: 'remote' as const, + name: 'no-card-agent', + description: 'Missing card info', + } as Parameters[0]; + + expect(() => markdownToAgentDefinition(markdown)).toThrow( + /neither agent_card_json nor agent_card_url/, + ); + }); + }); + + describe('loadAgentsFromDirectory', () => { + it('should load definitions from a directory (Markdown only)', async () => { + await writeAgentMarkdown( + `--- +name: agent-1 +description: Agent 1 +--- +Prompt 1`, + 'valid.md', + ); + + // Create a non-supported file + await fs.writeFile(path.join(tempDir, 'other.txt'), 'content'); + + // Create a hidden file + await writeAgentMarkdown( + `--- +name: hidden +description: Hidden +--- +Hidden`, + '_hidden.md', + ); + + const result = await loadAgentsFromDirectory(tempDir); + expect(result.agents).toHaveLength(1); + expect(result.agents[0].name).toBe('agent-1'); + expect(result.errors).toHaveLength(0); + }); + + it('should return empty result if directory does not exist', async () => { + const nonExistentDir = path.join(tempDir, 'does-not-exist'); + const result = await loadAgentsFromDirectory(nonExistentDir); + expect(result.agents).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('should capture errors for malformed individual files', async () => { + // Create a malformed Markdown file + await writeAgentMarkdown('invalid markdown', 'malformed.md'); + + const result = await loadAgentsFromDirectory(tempDir); + expect(result.agents).toHaveLength(0); + expect(result.errors).toHaveLength(1); + }); + }); + + describe('remote agent auth configuration', () => { + it('should parse remote agent with apiKey auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: api-key-agent +agent_card_url: https://example.com/card +auth: + type: apiKey + key: $MY_API_KEY + name: X-Custom-Key +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'api-key-agent', + auth: { + type: 'apiKey', + key: '$MY_API_KEY', + name: 'X-Custom-Key', + }, + }); + }); + + it('should parse remote agent with http Bearer auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: bearer-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Bearer + token: $BEARER_TOKEN +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'bearer-agent', + auth: { + type: 'http', + scheme: 'Bearer', + token: '$BEARER_TOKEN', + }, + }); + }); + + it('should parse remote agent with http Basic auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: basic-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Basic + username: $AUTH_USER + password: $AUTH_PASS +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'basic-agent', + auth: { + type: 'http', + scheme: 'Basic', + username: '$AUTH_USER', + password: '$AUTH_PASS', + }, + }); + }); + + it('should parse remote agent with Digest via raw value', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: digest-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Digest + value: username="admin", response="abc123" +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'digest-agent', + auth: { + type: 'http', + scheme: 'Digest', + value: 'username="admin", response="abc123"', + }, + }); + }); + + it('should parse remote agent with generic raw auth value', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: raw-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: CustomScheme + value: raw-token-value +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'raw-agent', + auth: { + type: 'http', + scheme: 'CustomScheme', + value: 'raw-token-value', + }, + }); + }); + + it('should throw error for Bearer auth without token', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-bearer +agent_card_url: https://example.com/card +auth: + type: http + scheme: Bearer +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Bearer scheme requires "token"/, + ); + }); + + it('should throw error for Basic auth without credentials', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-basic +agent_card_url: https://example.com/card +auth: + type: http + scheme: Basic + username: user +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Basic authentication requires "password"/, + ); + }); + + it('should throw error for apiKey auth without key', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-apikey +agent_card_url: https://example.com/card +auth: + type: apiKey +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /auth\.key.*Required/, + ); + }); + + it('should convert auth config in markdownToAgentDefinition', () => { + const markdown = { + kind: 'remote' as const, + name: 'auth-agent', + agent_card_url: 'https://example.com/card', + auth: { + type: 'apiKey' as const, + key: '$API_KEY', + }, + }; + + const result = markdownToAgentDefinition(markdown); + expect(result).toMatchObject({ + kind: 'remote', + name: 'auth-agent', + auth: { + type: 'apiKey', + key: '$API_KEY', + }, + }); + }); + + it('should parse remote agent with oauth2 auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: oauth2-agent +agent_card_url: https://example.com/card +auth: + type: oauth + client_id: $MY_OAUTH_CLIENT_ID + scopes: + - read + - write +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'oauth2-agent', + auth: { + type: 'oauth', + client_id: '$MY_OAUTH_CLIENT_ID', + scopes: ['read', 'write'], + }, + }); + }); + + it('should parse remote agent with oauth2 auth including all fields', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: oauth2-full-agent +agent_card_url: https://example.com/card +auth: + type: oauth + client_id: my-client-id + client_secret: my-client-secret + scopes: + - openid + - profile + authorization_url: https://auth.example.com/authorize + token_url: https://auth.example.com/token + issuer: https://auth.example.com + audiences: + - audience1 + redirect_uri: http://localhost:8080/callback + token_param_name: access_token + registration_url: https://auth.example.com/register +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'oauth2-full-agent', + auth: { + type: 'oauth', + client_id: 'my-client-id', + client_secret: 'my-client-secret', + scopes: ['openid', 'profile'], + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + issuer: 'https://auth.example.com', + audiences: ['audience1'], + redirect_uri: 'http://localhost:8080/callback', + token_param_name: 'access_token', + registration_url: 'https://auth.example.com/register', + }, + }); + }); + + it('should parse remote agent with minimal oauth2 config (type only)', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: oauth2-minimal-agent +agent_card_url: https://example.com/card +auth: + type: oauth +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'oauth2-minimal-agent', + auth: { + type: 'oauth', + }, + }); + }); + + it('should reject oauth2 auth with invalid authorization_url', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-oauth2-agent +agent_card_url: https://example.com/card +auth: + type: oauth + client_id: my-client + authorization_url: not-a-valid-url +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow(/Invalid url/); + }); + + it('should reject oauth2 auth with invalid token_url', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-oauth2-agent +agent_card_url: https://example.com/card +auth: + type: oauth + client_id: my-client + token_url: not-a-valid-url +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow(/Invalid url/); + }); + + it('should convert oauth2 auth config in markdownToAgentDefinition', () => { + const markdown = { + kind: 'remote' as const, + name: 'oauth2-convert-agent', + agent_card_url: 'https://example.com/card', + auth: { + type: 'oauth' as const, + client_id: '$MY_CLIENT_ID', + scopes: ['read'], + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + }, + }; + + const result = markdownToAgentDefinition(markdown); + expect(result).toMatchObject({ + kind: 'remote', + name: 'oauth2-convert-agent', + auth: { + type: 'oauth2', + client_id: '$MY_CLIENT_ID', + scopes: ['read'], + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + }, + }); + }); + + it('should throw an error for an unknown auth type in markdownToAgentDefinition', () => { + const markdown = { + kind: 'remote' as const, + name: 'unknown-auth-agent', + agent_card_url: 'https://example.com/card', + auth: { + type: 'apiKey' as const, + key: 'some-key', + }, + }; + + // Mutate the object at runtime to bypass TypeScript compile-time checks cleanly + Object.assign(markdown.auth, { type: 'some-unknown-type' }); + + expect(() => markdownToAgentDefinition(markdown)).toThrow( + /Unknown auth type: some-unknown-type/, + ); + }); + }); + + describe('getAgentCardLoadOptions', () => { + it('should return json options when agentCardJson is present', () => { + const def = { + name: 'test', + agentCardJson: '{"url":"http://x"}', + } as RemoteAgentDefinition; + const opts = getAgentCardLoadOptions(def); + expect(opts).toEqual({ type: 'json', json: '{"url":"http://x"}' }); + }); + + it('should return url options when agentCardUrl is present', () => { + const def = { + name: 'test', + agentCardUrl: 'http://x/card', + } as RemoteAgentDefinition; + const opts = getAgentCardLoadOptions(def); + expect(opts).toEqual({ type: 'url', url: 'http://x/card' }); + }); + + it('should prefer agentCardJson over agentCardUrl when both present', () => { + const def = { + name: 'test', + agentCardJson: '{"url":"http://x"}', + agentCardUrl: 'http://x/card', + } as RemoteAgentDefinition; + const opts = getAgentCardLoadOptions(def); + expect(opts.type).toBe('json'); + }); + + it('should throw when neither is present', () => { + const def = { name: 'orphan' } as RemoteAgentDefinition; + expect(() => getAgentCardLoadOptions(def)).toThrow( + /Remote agent 'orphan' has neither agentCardUrl nor agentCardJson/, + ); + }); + }); + + describe('getRemoteAgentTargetUrl', () => { + it('should return agentCardUrl when present', () => { + const def = { + name: 'test', + agentCardUrl: 'http://x/card', + } as RemoteAgentDefinition; + expect(getRemoteAgentTargetUrl(def)).toBe('http://x/card'); + }); + + it('should extract url from agentCardJson when agentCardUrl is absent', () => { + const def = { + name: 'test', + agentCardJson: JSON.stringify({ + name: 'agent', + url: 'https://example.com/agent', + }), + } as RemoteAgentDefinition; + expect(getRemoteAgentTargetUrl(def)).toBe('https://example.com/agent'); + }); + + it('should return undefined when JSON has no url field', () => { + const def = { + name: 'test', + agentCardJson: JSON.stringify({ name: 'agent' }), + } as RemoteAgentDefinition; + expect(getRemoteAgentTargetUrl(def)).toBeUndefined(); + }); + + it('should return undefined when agentCardJson is invalid JSON', () => { + const def = { + name: 'test', + agentCardJson: 'not json', + } as RemoteAgentDefinition; + expect(getRemoteAgentTargetUrl(def)).toBeUndefined(); + }); + + it('should return undefined when neither field is present', () => { + const def = { name: 'test' } as RemoteAgentDefinition; + expect(getRemoteAgentTargetUrl(def)).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts new file mode 100644 index 0000000000000000000000000000000000000000..940acde38f2c94352c067af3543b690e1eb01859 --- /dev/null +++ b/packages/core/src/agents/agentLoader.ts @@ -0,0 +1,697 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { load } from 'js-yaml'; +import * as fs from 'node:fs/promises'; +import { type Dirent } from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { z } from 'zod'; +import { + type AgentDefinition, + type RemoteAgentDefinition, + DEFAULT_MAX_TURNS, + DEFAULT_MAX_TIME_MINUTES, +} from './types.js'; +import type { A2AAuthConfig } from './auth-provider/types.js'; +import { + MCPServerConfig, + AuthProviderType, + type MCPOAuthConfig, +} from '../config/config.js'; +import { isValidToolName } from '../tools/tool-names.js'; +import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; +import { getErrorMessage } from '../utils/errors.js'; + +/** + * Error thrown when an agent definition is invalid or cannot be loaded. + */ +export class AgentLoadError extends Error { + constructor( + public filePath: string, + message: string, + ) { + super(`Failed to load agent from ${filePath}: ${message}`); + this.name = 'AgentLoadError'; + } +} + +/** + * Result of loading agents from a directory. + */ +export interface AgentLoadResult { + agents: AgentDefinition[]; + errors: AgentLoadError[]; +} + +const nameSchema = z + .string() + .regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'); + +const mcpServerSchema = z.object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + cwd: z.string().optional(), + url: z.string().optional(), + http_url: z.string().optional(), + headers: z.record(z.string()).optional(), + tcp: z.string().optional(), + type: z.enum(['sse', 'http']).optional(), + timeout: z.number().optional(), + trust: z.boolean().optional(), + description: z.string().optional(), + include_tools: z.array(z.string()).optional(), + exclude_tools: z.array(z.string()).optional(), + auth: z + .union([ + z.object({ + type: z.literal('google-credentials'), + scopes: z.array(z.string()).optional(), + }), + z.object({ + type: z.literal('oauth'), + client_id: z.string().optional(), + client_secret: z.string().optional(), + scopes: z.array(z.string()).optional(), + authorization_url: z.string().url().optional(), + token_url: z.string().url().optional(), + issuer: z.string().url().optional(), + audiences: z.array(z.string()).optional(), + redirect_uri: z.string().url().optional(), + token_param_name: z.string().optional(), + registration_url: z.string().url().optional(), + }), + ]) + .optional(), +}); + +const localAgentSchema = z + .object({ + kind: z.literal('local').optional().default('local'), + name: nameSchema, + description: z.string().min(1), + display_name: z.string().optional(), + tools: z + .array( + z + .string() + .refine( + (val: string) => isValidToolName(val, { allowWildcards: true }), + { + message: 'Invalid tool name', + }, + ), + ) + .optional(), + mcp_servers: z.record(mcpServerSchema).optional(), + model: z.string().optional(), + temperature: z.number().optional(), + max_turns: z.number().int().positive().optional(), + timeout_mins: z.number().int().positive().optional(), + }) + .strict(); + +type FrontmatterLocalAgentDefinition = z.infer & { + system_prompt: string; +}; + +// Base fields shared by all auth configs. +const baseAuthFields = {}; + +const apiKeyAuthSchema = z.object({ + ...baseAuthFields, + type: z.literal('apiKey'), + key: z.string().min(1, 'API key is required'), + name: z.string().optional(), +}); + +const httpAuthSchema = z.object({ + ...baseAuthFields, + type: z.literal('http'), + scheme: z.string().min(1), + token: z.string().min(1).optional(), + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), + value: z.string().min(1).optional(), +}); + +const googleCredentialsAuthSchema = z.object({ + ...baseAuthFields, + type: z.literal('google-credentials'), + scopes: z.array(z.string()).optional(), +}); + +const oauth2AuthSchema = z.object({ + ...baseAuthFields, + type: z.literal('oauth'), + client_id: z.string().optional(), + client_secret: z.string().optional(), + scopes: z.array(z.string()).optional(), + authorization_url: z.string().url().optional(), + token_url: z.string().url().optional(), + issuer: z.string().url().optional(), + audiences: z.array(z.string()).optional(), + redirect_uri: z.string().url().optional(), + token_param_name: z.string().optional(), + registration_url: z.string().url().optional(), +}); + +const authConfigSchema = z + .discriminatedUnion('type', [ + apiKeyAuthSchema, + httpAuthSchema, + googleCredentialsAuthSchema, + oauth2AuthSchema, + ]) + .superRefine((data, ctx) => { + if (data.type === 'http') { + if (data.value) return; + if (data.scheme === 'Bearer') { + if (!data.token) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Bearer scheme requires "token"', + path: ['token'], + }); + } + } else if (data.scheme === 'Basic') { + if (!data.username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "username"', + path: ['username'], + }); + } + if (!data.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "password"', + path: ['password'], + }); + } + } else { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `HTTP scheme "${data.scheme}" requires "value"`, + path: ['value'], + }); + } + } + }); + +type FrontmatterAuthConfig = z.infer; + +const baseRemoteAgentSchema = z.object({ + kind: z.literal('remote').optional().default('remote'), + name: nameSchema, + description: z.string().optional(), + display_name: z.string().optional(), + auth: authConfigSchema.optional(), +}); + +const remoteAgentUrlSchema = baseRemoteAgentSchema + .extend({ + agent_card_url: z.string().url(), + agent_card_json: z.undefined().optional(), + }) + .strict(); + +const remoteAgentJsonSchema = baseRemoteAgentSchema + .extend({ + agent_card_url: z.undefined().optional(), + agent_card_json: z.string().refine( + (val: string) => { + try { + JSON.parse(val); + return true; + } catch { + return false; + } + }, + { message: 'agent_card_json must be valid JSON' }, + ), + }) + .strict(); + +const remoteAgentSchema = z.union([ + remoteAgentUrlSchema, + remoteAgentJsonSchema, +]); + +type FrontmatterRemoteAgentDefinition = z.infer; + +type FrontmatterAgentDefinition = + | FrontmatterLocalAgentDefinition + | FrontmatterRemoteAgentDefinition; + +const agentUnionOptions = [ + { label: 'Local Agent' }, + { label: 'Remote Agent' }, + { label: 'Remote Agent' }, +]; + +const remoteAgentsListSchema = z.array(remoteAgentSchema); + +const markdownFrontmatterSchema = z.union([ + localAgentSchema, + remoteAgentUrlSchema, + remoteAgentJsonSchema, +]); + +function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined { + if (typeof rawInput !== 'object' || rawInput === null) return undefined; + const input = rawInput as Partial & + Partial; + + if (input.kind === 'local') return 'local'; + if (input.kind === 'remote') return 'remote'; + + const hasLocalKeys = + 'tools' in input || + 'mcp_servers' in input || + 'model' in input || + 'temperature' in input || + 'max_turns' in input || + 'timeout_mins' in input; + const hasRemoteKeys = + 'agent_card_url' in input || 'auth' in input || 'agent_card_json' in input; + + if (hasLocalKeys && !hasRemoteKeys) return 'local'; + if (hasRemoteKeys && !hasLocalKeys) return 'remote'; + + return undefined; +} + +function formatZodError( + error: z.ZodError, + context: string, + rawInput?: unknown, +): string { + const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined; + + const formatIssues = (issues: z.ZodIssue[], unionPrefix?: string): string[] => + issues.flatMap((i) => { + // Handle union errors specifically to give better context + if (i.code === z.ZodIssueCode.invalid_union) { + return i.unionErrors.flatMap((unionError, index) => { + const label = unionPrefix + ? unionPrefix + : ((agentUnionOptions[index] as { label?: string })?.label ?? + `Branch #${index + 1}`); + + if (intendedKind === 'local' && label === 'Remote Agent') return []; + if (intendedKind === 'remote' && label === 'Local Agent') return []; + + return formatIssues(unionError.issues, label); + }); + } + const prefix = unionPrefix ? `(${unionPrefix}) ` : ''; + const path = i.path.length > 0 ? `${i.path.join('.')}: ` : ''; + return `${prefix}${path}${i.message}`; + }); + + const formatted = Array.from(new Set(formatIssues(error.issues))).join('\n'); + return `${context}:\n${formatted}`; +} + +/** + * Parses and validates an agent Markdown file with frontmatter. + * + * @param filePath Path to the Markdown file. + * @param content Optional pre-loaded content of the file. + * @returns An array containing the single parsed agent definition. + * @throws AgentLoadError if parsing or validation fails. + */ +export async function parseAgentMarkdown( + filePath: string, + content?: string, +): Promise { + let fileContent: string; + if (content !== undefined) { + fileContent = content; + } else { + try { + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + throw new AgentLoadError( + filePath, + `Could not read file: ${getErrorMessage(error)}`, + ); + } + } + + // Split frontmatter and body + const match = fileContent.match(FRONTMATTER_REGEX); + if (!match) { + throw new AgentLoadError( + filePath, + 'Invalid agent definition: Missing mandatory YAML frontmatter. Agent Markdown files MUST start with YAML frontmatter enclosed in triple-dashes "---" (e.g., ---\nname: my-agent\n---).', + ); + } + + const frontmatterStr = match[1]; + const body = match[2] || ''; + + let rawFrontmatter: unknown; + try { + rawFrontmatter = load(frontmatterStr); + } catch (error) { + throw new AgentLoadError( + filePath, + `YAML frontmatter parsing failed: ${getErrorMessage(error)}`, + ); + } + + // Handle array of remote agents + if (Array.isArray(rawFrontmatter)) { + const result = remoteAgentsListSchema.safeParse(rawFrontmatter); + if (!result.success) { + throw new AgentLoadError( + filePath, + `Validation failed: ${formatZodError(result.error, 'Remote Agents List')}`, + ); + } + return result.data.map((agent) => ({ + ...agent, + kind: 'remote', + })); + } + + const result = markdownFrontmatterSchema.safeParse(rawFrontmatter); + + if (!result.success) { + throw new AgentLoadError( + filePath, + `Validation failed: ${formatZodError(result.error, 'Agent Definition', rawFrontmatter)}`, + ); + } + + const frontmatter = result.data; + + if (frontmatter.kind === 'remote') { + return [ + { + ...frontmatter, + kind: 'remote', + }, + ]; + } + + // Construct the local agent definition + return [ + { + ...frontmatter, + kind: 'local', + system_prompt: body.trim(), + }, + ]; +} + +/** + * Converts frontmatter auth config to the internal A2AAuthConfig type. + * This handles the mapping from snake_case YAML to the internal type structure. + */ +function convertFrontmatterAuthToConfig( + frontmatter: FrontmatterAuthConfig, +): A2AAuthConfig { + switch (frontmatter.type) { + case 'apiKey': + return { + type: 'apiKey', + key: frontmatter.key, + name: frontmatter.name, + }; + + case 'google-credentials': + return { + type: 'google-credentials', + scopes: frontmatter.scopes, + }; + + case 'http': + if (frontmatter.value) { + return { + type: 'http', + scheme: frontmatter.scheme, + value: frontmatter.value, + }; + } + switch (frontmatter.scheme) { + case 'Bearer': + // Token is required by schema validation + return { + type: 'http', + scheme: 'Bearer', + + token: frontmatter.token!, + }; + case 'Basic': + // Username/password are required by schema validation + return { + type: 'http', + scheme: 'Basic', + username: frontmatter.username!, + password: frontmatter.password!, + }; + default: + throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`); + } + + case 'oauth': + return { + type: 'oauth2', + client_id: frontmatter.client_id, + client_secret: frontmatter.client_secret, + scopes: frontmatter.scopes, + authorization_url: frontmatter.authorization_url, + token_url: frontmatter.token_url, + issuer: frontmatter.issuer, + audiences: frontmatter.audiences, + redirect_uri: frontmatter.redirect_uri, + token_param_name: frontmatter.token_param_name, + registration_url: frontmatter.registration_url, + }; + + default: { + const exhaustive: never = frontmatter; + const raw: unknown = exhaustive; + if (typeof raw === 'object' && raw !== null && 'type' in raw) { + throw new Error(`Unknown auth type: ${String(raw['type'])}`); + } + throw new Error('Unknown auth type'); + } + } +} + +/** + * Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure. + * + * @param markdown The parsed Markdown/Frontmatter definition. + * @param metadata Optional metadata including hash and file path. + * @returns The internal AgentDefinition. + */ +export function markdownToAgentDefinition( + markdown: FrontmatterAgentDefinition, + metadata?: { hash?: string; filePath?: string }, +): AgentDefinition { + const inputConfig = { + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task for the agent.', + }, + }, + // query is not required because it defaults to "Get Started!" if not provided + required: [], + }, + }; + + if (markdown.kind === 'remote') { + const base: RemoteAgentDefinition = { + kind: 'remote', + name: markdown.name, + description: markdown.description || '', + displayName: markdown.display_name, + auth: markdown.auth + ? convertFrontmatterAuthToConfig(markdown.auth) + : undefined, + inputConfig, + metadata, + }; + + if ( + 'agent_card_json' in markdown && + markdown.agent_card_json !== undefined + ) { + base.agentCardJson = markdown.agent_card_json; + return base; + } + if ('agent_card_url' in markdown && markdown.agent_card_url !== undefined) { + base.agentCardUrl = markdown.agent_card_url; + return base; + } + + throw new AgentLoadError( + metadata?.filePath || 'unknown', + 'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent', + ); + } + + // If a model is specified, use it. Otherwise, inherit + const modelName = markdown.model || 'inherit'; + + const mcpServers: Record = {}; + if (markdown.mcp_servers) { + for (const [name, config] of Object.entries(markdown.mcp_servers)) { + let authProviderType: AuthProviderType | undefined = undefined; + let oauth: MCPOAuthConfig | undefined = undefined; + + if (config.auth) { + if (config.auth.type === 'google-credentials') { + authProviderType = AuthProviderType.GOOGLE_CREDENTIALS; + oauth = { + enabled: true, + scopes: config.auth.scopes, + }; + } else if (config.auth.type === 'oauth') { + oauth = { + enabled: true, + clientId: config.auth.client_id, + clientSecret: config.auth.client_secret, + scopes: config.auth.scopes, + authorizationUrl: config.auth.authorization_url, + tokenUrl: config.auth.token_url, + issuer: config.auth.issuer, + audiences: config.auth.audiences, + redirectUri: config.auth.redirect_uri, + tokenParamName: config.auth.token_param_name, + registrationUrl: config.auth.registration_url, + }; + } + } + + mcpServers[name] = new MCPServerConfig( + config.command, + config.args, + config.env, + config.cwd, + config.url, + config.http_url, + config.headers, + config.tcp, + config.type, + config.timeout, + config.trust, + config.description, + config.include_tools, + config.exclude_tools, + undefined, // extension + oauth, + authProviderType, + ); + } + } + + return { + kind: 'local', + name: markdown.name, + description: markdown.description, + displayName: markdown.display_name, + promptConfig: { + systemPrompt: markdown.system_prompt, + query: '${query}', + }, + modelConfig: { + model: modelName, + generateContentConfig: { + temperature: markdown.temperature ?? 1, + topP: 0.95, + }, + }, + runConfig: { + maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS, + maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES, + }, + toolConfig: markdown.tools + ? { + tools: markdown.tools, + } + : undefined, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + inputConfig, + metadata, + }; +} + +/** + * Loads all agents from a specific directory. + * Ignores files starting with _ and non-supported extensions. + * Supported extensions: .md + * + * @param dir Directory path to scan. + * @returns Object containing successfully loaded agents and any errors. + */ +export async function loadAgentsFromDirectory( + dir: string, +): Promise { + const result: AgentLoadResult = { + agents: [], + errors: [], + }; + + let dirEntries: Dirent[]; + try { + dirEntries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + // If directory doesn't exist, just return empty + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return result; + } + result.errors.push( + new AgentLoadError( + dir, + `Could not list directory: ${getErrorMessage(error)}`, + ), + ); + return result; + } + + const files = dirEntries.filter( + (entry) => + entry.isFile() && + !entry.name.startsWith('_') && + entry.name.endsWith('.md'), + ); + + for (const entry of files) { + const filePath = path.join(dir, entry.name); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const hash = crypto.createHash('sha256').update(content).digest('hex'); + const agentDefs = await parseAgentMarkdown(filePath, content); + for (const def of agentDefs) { + const agent = markdownToAgentDefinition(def, { hash, filePath }); + result.agents.push(agent); + } + } catch (error) { + if (error instanceof AgentLoadError) { + result.errors.push(error); + } else { + result.errors.push( + new AgentLoadError( + filePath, + `Unexpected error: ${getErrorMessage(error)}`, + ), + ); + } + } + } + + return result; +} diff --git a/packages/core/src/agents/auth-provider/api-key-provider.test.ts b/packages/core/src/agents/auth-provider/api-key-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..de07bc1cbb58db88170155928c336eb837c52802 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.test.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; +import * as resolver from './value-resolver.js'; + +vi.mock('./value-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveAuthValue: vi.fn(), + }; +}); + +describe('ApiKeyAuthProvider', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with literal API key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-api-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-api-key' }); + }); + + it('should resolve API key from environment variable', async () => { + vi.stubEnv('TEST_API_KEY', 'env-api-key'); + vi.mocked(resolver.resolveAuthValue).mockResolvedValue('env-api-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$TEST_API_KEY', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'env-api-key' }); + }); + + it('should throw if environment variable is not set', async () => { + vi.mocked(resolver.resolveAuthValue).mockRejectedValue( + new Error("Environment variable 'MISSING_KEY_12345' is not set"), + ); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$MISSING_KEY_12345', + }); + + await expect(provider.initialize()).rejects.toThrow( + "Environment variable 'MISSING_KEY_12345' is not set", + ); + }); + }); + + describe('headers', () => { + it('should throw if not initialized', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + + await expect(provider.headers()).rejects.toThrow('not initialized'); + }); + + it('should use custom header name', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + name: 'X-Custom-Auth', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-Custom-Auth': 'my-key' }); + }); + + it('should use default header name X-API-Key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-key' }); + }); + }); + + describe('shouldRetryWithHeaders', () => { + it('should return undefined for non-auth errors', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 500 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for literal keys on 401 (same headers would fail again)', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for env-var keys on 403', async () => { + vi.stubEnv('RETRY_TEST_KEY', 'some-key'); + vi.mocked(resolver.resolveAuthValue).mockResolvedValue('some-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$RETRY_TEST_KEY', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 403 }), + ); + expect(result).toBeUndefined(); + }); + + it('should re-resolve and return headers for command keys on 401', async () => { + vi.mocked(resolver.resolveAuthValue) + .mockResolvedValueOnce('initial-key') + .mockResolvedValueOnce('refreshed-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!some command', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toEqual({ 'X-API-Key': 'refreshed-key' }); + }); + + it('should stop retrying after MAX_AUTH_RETRIES', async () => { + vi.mocked(resolver.resolveAuthValue).mockResolvedValue('rotating-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!some command', + }); + await provider.initialize(); + + const r1 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r1).toBeDefined(); + + const r2 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r2).toBeDefined(); + + const r3 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r3).toBeUndefined(); + }); + }); + + describe('type property', () => { + it('should have type apiKey', () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test', + }); + expect(provider.type).toBe('apiKey'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/api-key-provider.ts b/packages/core/src/agents/auth-provider/api-key-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..000236abdaf0ba0af7693e4450f789861f0a0bc2 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { ApiKeyAuthConfig } from './types.js'; +import { resolveAuthValue, needsResolution } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +const DEFAULT_HEADER_NAME = 'X-API-Key'; + +/** + * Authentication provider for API Key authentication. + * Sends the API key as an HTTP header. + * + * The API key value can be: + * - A literal string + * - An environment variable reference ($ENV_VAR) + * - A shell command (!command) + */ +export class ApiKeyAuthProvider extends BaseA2AAuthProvider { + readonly type = 'apiKey'; + + private resolvedKey: string | undefined; + private readonly headerName: string; + + constructor(private readonly config: ApiKeyAuthConfig) { + super(); + this.headerName = config.name ?? DEFAULT_HEADER_NAME; + } + + override async initialize(): Promise { + if (needsResolution(this.config.key)) { + this.resolvedKey = await resolveAuthValue(this.config.key); + debugLogger.debug( + `[ApiKeyAuthProvider] Resolved API key from: ${this.config.key.startsWith('$') ? 'env var' : 'command'}`, + ); + } else { + this.resolvedKey = this.config.key; + } + } + + async headers(): Promise { + if (!this.resolvedKey) { + throw new Error( + 'ApiKeyAuthProvider not initialized. Call initialize() first.', + ); + } + return { [this.headerName]: this.resolvedKey }; + } + + /** + * Re-resolve command-based API keys on auth failure. + */ + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + this.authRetryCount = 0; + return undefined; + } + + // Only retry for command-based keys that may resolve to a new value. + // Literal and env-var keys would just resend the same failing headers. + if (!this.config.key.startsWith('!') || this.config.key.startsWith('!!')) { + return undefined; + } + + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + this.authRetryCount++; + + debugLogger.debug( + '[ApiKeyAuthProvider] Re-resolving API key after auth failure', + ); + this.resolvedKey = await resolveAuthValue(this.config.key); + + return this.headers(); + } +} diff --git a/packages/core/src/agents/auth-provider/base-provider.test.ts b/packages/core/src/agents/auth-provider/base-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc9a20eecdd643840d937cf91487cdb2c0f19bb0 --- /dev/null +++ b/packages/core/src/agents/auth-provider/base-provider.test.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { A2AAuthProviderType } from './types.js'; + +/** + * Concrete implementation of BaseA2AAuthProvider for testing. + */ +class TestAuthProvider extends BaseA2AAuthProvider { + readonly type: A2AAuthProviderType = 'apiKey'; + private testHeaders: HttpHeaders; + + constructor(headers: HttpHeaders = { Authorization: 'test-token' }) { + super(); + this.testHeaders = headers; + } + + async headers(): Promise { + return this.testHeaders; + } + + setHeaders(headers: HttpHeaders): void { + this.testHeaders = headers; + } +} + +describe('BaseA2AAuthProvider', () => { + describe('shouldRetryWithHeaders', () => { + it('should return headers for 401 response', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response = new Response(null, { status: 401 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toEqual({ Authorization: 'Bearer token' }); + }); + + it('should return headers for 403 response', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response = new Response(null, { status: 403 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toEqual({ Authorization: 'Bearer token' }); + }); + + it('should return undefined for 200 response', async () => { + const provider = new TestAuthProvider(); + const response = new Response(null, { status: 200 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for 500 response', async () => { + const provider = new TestAuthProvider(); + const response = new Response(null, { status: 500 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for 404 response', async () => { + const provider = new TestAuthProvider(); + const response = new Response(null, { status: 404 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toBeUndefined(); + }); + + it('should call headers() to get fresh headers on retry', async () => { + const provider = new TestAuthProvider({ Authorization: 'old-token' }); + const response = new Response(null, { status: 401 }); + + // Change headers before retry + provider.setHeaders({ Authorization: 'new-token' }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toEqual({ Authorization: 'new-token' }); + }); + + it('should retry up to 2 times on 401/403', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response401 = new Response(null, { status: 401 }); + + // First retry should succeed + const result1 = await provider.shouldRetryWithHeaders({}, response401); + expect(result1).toEqual({ Authorization: 'Bearer token' }); + + // Second retry should succeed + const result2 = await provider.shouldRetryWithHeaders({}, response401); + expect(result2).toEqual({ Authorization: 'Bearer token' }); + }); + + it('should return undefined after max retries exceeded', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response401 = new Response(null, { status: 401 }); + + // Exhaust retries + await provider.shouldRetryWithHeaders({}, response401); // retry 1 + await provider.shouldRetryWithHeaders({}, response401); // retry 2 + + // Third attempt should return undefined + const result = await provider.shouldRetryWithHeaders({}, response401); + expect(result).toBeUndefined(); + }); + + it('should reset retry count on successful response', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response401 = new Response(null, { status: 401 }); + const response200 = new Response(null, { status: 200 }); + + // Use up retries + await provider.shouldRetryWithHeaders({}, response401); // retry 1 + await provider.shouldRetryWithHeaders({}, response401); // retry 2 + + // Success resets counter + await provider.shouldRetryWithHeaders({}, response200); + + // Should be able to retry again + const result = await provider.shouldRetryWithHeaders({}, response401); + expect(result).toEqual({ Authorization: 'Bearer token' }); + }); + }); + + describe('initialize', () => { + it('should be a no-op by default', async () => { + const provider = new TestAuthProvider(); + + // Should not throw + await expect(provider.initialize()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c8fd3ee2a7206cd1d02058a03f15140498ea693 --- /dev/null +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import type { A2AAuthProvider, A2AAuthProviderType } from './types.js'; + +/** + * Abstract base class for A2A authentication providers. + * Provides default implementations for optional methods. + */ +export abstract class BaseA2AAuthProvider implements A2AAuthProvider { + /** + * The type of authentication provider. + */ + abstract readonly type: A2AAuthProviderType; + + /** + * Get the HTTP headers to include in requests. + * Subclasses must implement this method. + */ + abstract headers(): Promise; + + protected static readonly MAX_AUTH_RETRIES = 2; + protected authRetryCount = 0; + + /** + * Check if a request should be retried with new headers. + * + * The default implementation checks for 401/403 status codes and + * returns fresh headers for retry. Subclasses can override for + * custom retry logic. + * + * @param _req The original request init + * @param res The response from the server + * @returns New headers for retry, or undefined if no retry should be made + */ + async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status === 401 || res.status === 403) { + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; // Max retries exceeded + } + this.authRetryCount++; + return this.headers(); + } + // Reset count if not an auth error + this.authRetryCount = 0; + return undefined; + } + + /** + * Initialize the provider. Override in subclasses that need async setup. + */ + async initialize(): Promise { + // Default: no-op + } +} diff --git a/packages/core/src/agents/auth-provider/credential-leak-prevention.test.ts b/packages/core/src/agents/auth-provider/credential-leak-prevention.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2e349ddf5394433c8e8e002f9944e00052e63e7 --- /dev/null +++ b/packages/core/src/agents/auth-provider/credential-leak-prevention.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js'; +import type { GoogleCredentialsAuthConfig } from './types.js'; +import { GoogleAuth } from 'google-auth-library'; + +vi.mock('google-auth-library', () => ({ + GoogleAuth: vi.fn(), +})); + +describe('Credential Leak Prevention (RCA / PoC Verification)', () => { + const mockConfig: GoogleCredentialsAuthConfig = { + type: 'google-credentials', + }; + + beforeEach(() => { + vi.clearAllMocks(); + (GoogleAuth as unknown as Mock).mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: 'leaked-token' }), + credentials: { expiry_date: Date.now() + 3600 * 1000 }, + }), + getIdTokenClient: vi.fn().mockResolvedValue({ + idTokenProvider: { + fetchIdToken: vi.fn().mockResolvedValue('leaked-id-token'), + }, + }), + })); + }); + + it('should FAIL (throw error) when trying to initialize with an untrusted arbitrary remote agent URL (reproducing vulnerability prevention)', () => { + // This test simulates the reproduction scenario: registering a remote agent with an arbitrary external URL + // e.g., http://127.0.0.1:1337 or https://malicious-agent.evil.com + const untrustedUrls = [ + { + url: 'http://127.0.0.1:1337/.well-known/agent.json', + error: /requires HTTPS/, + }, + { + url: 'https://malicious-agent.evil.com/card', + error: /is not an allowed host/, + }, + { + url: 'https://untrusted-third-party.com/agent', + error: /is not an allowed host/, + }, + ]; + + for (const item of untrustedUrls) { + expect(() => { + new GoogleCredentialsAuthProvider(mockConfig, item.url); + }).toThrow(item.error); + } + }); + + it('should SUCCEED only for allowed Google Services (proving the allowlist constraint)', () => { + const trustedUrls = [ + 'https://language.googleapis.com/v1/models', + 'https://vertex-ai-agent.googleapis.com/agent', + 'https://my-secure-service-abc.run.app/card', + ]; + + for (const url of trustedUrls) { + expect(() => { + new GoogleCredentialsAuthProvider(mockConfig, url); + }).not.toThrow(); + } + }); +}); diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..857d68ff457b8f45cc3ef216778088b4ef300633 --- /dev/null +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -0,0 +1,564 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { A2AAuthProviderFactory } from './factory.js'; +import type { AgentCard, SecurityScheme } from '@a2a-js/sdk'; +import type { A2AAuthConfig } from './types.js'; + +// Mock token storage so OAuth2AuthProvider.initialize() works without disk I/O. +vi.mock('../../mcp/oauth-token-storage.js', () => { + const MCPOAuthTokenStorage = vi.fn().mockImplementation(() => ({ + getCredentials: vi.fn().mockResolvedValue(null), + saveToken: vi.fn().mockResolvedValue(undefined), + deleteCredentials: vi.fn().mockResolvedValue(undefined), + isTokenExpired: vi.fn().mockReturnValue(false), + })); + return { MCPOAuthTokenStorage }; +}); + +describe('A2AAuthProviderFactory', () => { + describe('validateAuthConfig', () => { + describe('when no security schemes required', () => { + it('should return valid when securitySchemes is undefined', () => { + const result = A2AAuthProviderFactory.validateAuthConfig( + undefined, + undefined, + ); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid when securitySchemes is empty', () => { + const result = A2AAuthProviderFactory.validateAuthConfig(undefined, {}); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid when auth config provided but not required', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'test-key', + }; + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + {}, + ); + expect(result).toEqual({ valid: true }); + }); + }); + + describe('when auth is required but not configured', () => { + it('should return invalid with diff', () => { + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + undefined, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff).toBeDefined(); + expect(result.diff?.requiredSchemes).toContain('apiKeyAuth'); + expect(result.diff?.configuredType).toBeUndefined(); + expect(result.diff?.missingConfig).toContain( + 'Authentication is required but not configured', + ); + }); + }); + + describe('apiKey scheme matching', () => { + it('should match apiKey config with apiKey scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'my-key', + }; + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match http config with apiKey scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Bearer', + token: 'my-token', + }; + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'apiKeyAuth' requires apiKey authentication", + ); + }); + }); + + describe('http scheme matching', () => { + it('should match http Bearer config with http Bearer scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Bearer', + token: 'my-token', + }; + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should match http Basic config with http Basic scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Basic', + username: 'user', + password: 'pass', + }; + const securitySchemes: Record = { + basicAuth: { + type: 'http', + scheme: 'Basic', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match http Basic config with http Bearer scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Basic', + username: 'user', + password: 'pass', + }; + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'bearerAuth' requires HTTP Bearer authentication, but Basic was configured", + ); + }); + + it('should match google-credentials with http Bearer scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'google-credentials', + }; + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + }); + + describe('oauth2 scheme matching', () => { + it('should match oauth2 config with oauth2 scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'oauth2', + }; + const securitySchemes: Record = { + oauth2Auth: { + type: 'oauth2', + flows: {}, + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match apiKey config with oauth2 scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'my-key', + }; + const securitySchemes: Record = { + oauth2Auth: { + type: 'oauth2', + flows: {}, + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'oauth2Auth' requires OAuth 2.0 authentication", + ); + }); + }); + + describe('openIdConnect scheme matching', () => { + it('should match openIdConnect config with openIdConnect scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'openIdConnect', + issuer_url: 'https://auth.example.com', + client_id: 'client-id', + }; + const securitySchemes: Record = { + oidcAuth: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://auth.example.com/.well-known/openid-configuration', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match google-credentials for openIdConnect scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'google-credentials', + }; + const securitySchemes: Record = { + oidcAuth: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://auth.example.com/.well-known/openid-configuration', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'oidcAuth' requires OpenID Connect authentication", + ); + }); + }); + + describe('mutualTLS scheme', () => { + it('should always fail for mutualTLS (not supported)', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'test', + }; + const securitySchemes: Record = { + mtlsAuth: { + type: 'mutualTLS', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'mtlsAuth' requires mTLS authentication (not yet supported)", + ); + }); + }); + + describe('multiple security schemes', () => { + it('should match if any scheme matches', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Bearer', + token: 'my-token', + }; + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + }); + }); + + describe('describeRequiredAuth', () => { + it('should describe apiKey scheme', () => { + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('API Key (apiKeyAuth): Send X-API-Key in header'); + }); + + it('should describe http Bearer scheme', () => { + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('HTTP Bearer (bearerAuth)'); + }); + + it('should describe http Basic scheme', () => { + const securitySchemes: Record = { + basicAuth: { + type: 'http', + scheme: 'Basic', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('HTTP Basic (basicAuth)'); + }); + + it('should describe oauth2 scheme', () => { + const securitySchemes: Record = { + oauth2Auth: { + type: 'oauth2', + flows: {}, + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('OAuth 2.0 (oauth2Auth)'); + }); + + it('should describe openIdConnect scheme', () => { + const securitySchemes: Record = { + oidcAuth: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://auth.example.com/.well-known/openid-configuration', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('OpenID Connect (oidcAuth)'); + }); + + it('should describe mutualTLS scheme', () => { + const securitySchemes: Record = { + mtlsAuth: { + type: 'mutualTLS', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('Mutual TLS (mtlsAuth)'); + }); + + it('should join multiple schemes with OR', () => { + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe( + 'API Key (apiKeyAuth): Send X-API-Key in header OR HTTP Bearer (bearerAuth)', + ); + }); + }); + + describe('create', () => { + it('should return undefined when no auth config and no security schemes', async () => { + const result = await A2AAuthProviderFactory.create({ + agentName: 'test-agent', + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when no auth config but AgentCard has security schemes', async () => { + const result = await A2AAuthProviderFactory.create({ + agentName: 'test-agent', + agentCard: { + securitySchemes: { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }, + } as unknown as AgentCard, + }); + + // Returns undefined - caller should prompt user to configure auth + expect(result).toBeUndefined(); + }); + + it('should create an ApiKeyAuthProvider for apiKey config', async () => { + const provider = await A2AAuthProviderFactory.create({ + authConfig: { + type: 'apiKey', + key: 'factory-test-key', + }, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('apiKey'); + const headers = await provider!.headers(); + expect(headers).toEqual({ 'X-API-Key': 'factory-test-key' }); + }); + + it('should create an OAuth2AuthProvider for oauth2 config', async () => { + const provider = await A2AAuthProviderFactory.create({ + agentName: 'my-oauth-agent', + authConfig: { + type: 'oauth2', + client_id: 'my-client', + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + scopes: ['read'], + }, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('oauth2'); + }); + + it('should create an OAuth2AuthProvider with agent card defaults', async () => { + const provider = await A2AAuthProviderFactory.create({ + agentName: 'card-oauth-agent', + authConfig: { + type: 'oauth2', + client_id: 'my-client', + }, + agentCard: { + securitySchemes: { + oauth: { + type: 'oauth2', + flows: { + authorizationCode: { + authorizationUrl: 'https://card.example.com/authorize', + tokenUrl: 'https://card.example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }, + }, + } as unknown as AgentCard, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('oauth2'); + }); + + it('should use "unknown" as agent name when agentName is not provided for oauth2', async () => { + const provider = await A2AAuthProviderFactory.create({ + authConfig: { + type: 'oauth2', + client_id: 'my-client', + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + }, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('oauth2'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d08d99b77f837dea5a7ee6bf717b078a4fc84b4 --- /dev/null +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -0,0 +1,268 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AgentCard, SecurityScheme } from '@a2a-js/sdk'; +import type { + A2AAuthConfig, + A2AAuthProvider, + AuthValidationResult, +} from './types.js'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; +import { HttpAuthProvider } from './http-provider.js'; +import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js'; + +export interface CreateAuthProviderOptions { + /** Required for OAuth/OIDC token storage. */ + agentName?: string; + authConfig?: A2AAuthConfig; + agentCard?: AgentCard; + /** Required by some providers (like google-credentials) to determine token audience. */ + targetUrl?: string; + /** URL to fetch the agent card from, used for OAuth2 URL discovery. */ + agentCardUrl?: string; +} + +/** + * Factory for creating A2A authentication providers. + * @see https://a2a-protocol.org/latest/specification/#451-securityscheme + */ +export class A2AAuthProviderFactory { + static async create( + options: CreateAuthProviderOptions, + ): Promise { + const { agentName: _agentName, authConfig, agentCard } = options; + + if (!authConfig) { + if ( + agentCard?.securitySchemes && + Object.keys(agentCard.securitySchemes).length > 0 + ) { + return undefined; // Caller should prompt user to configure auth + } + return undefined; + } + + switch (authConfig.type) { + case 'google-credentials': { + const provider = new GoogleCredentialsAuthProvider( + authConfig, + options.targetUrl, + ); + await provider.initialize(); + return provider; + } + + case 'apiKey': { + const provider = new ApiKeyAuthProvider(authConfig); + await provider.initialize(); + return provider; + } + + case 'http': { + const provider = new HttpAuthProvider(authConfig); + await provider.initialize(); + return provider; + } + + case 'oauth2': { + // Dynamic import to avoid pulling MCPOAuthTokenStorage into the + // factory's static module graph, which causes initialization + // conflicts with code_assist/oauth-credential-storage.ts. + const { OAuth2AuthProvider } = await import('./oauth2-provider.js'); + const provider = new OAuth2AuthProvider( + authConfig, + options.agentName ?? 'unknown', + agentCard, + options.agentCardUrl, + ); + await provider.initialize(); + return provider; + } + + case 'openIdConnect': + // TODO: Implement + throw new Error('openIdConnect auth provider not yet implemented'); + + default: { + const _exhaustive: never = authConfig; + throw new Error( + `Unknown auth type: ${(_exhaustive as A2AAuthConfig).type}`, + ); + } + } + } + + /** Create provider directly from config, bypassing AgentCard validation. */ + static async createFromConfig( + authConfig: A2AAuthConfig, + agentName?: string, + ): Promise { + const provider = await A2AAuthProviderFactory.create({ + authConfig, + agentName, + }); + + // create() returns undefined only when authConfig is missing. + // Since authConfig is required here, provider will always be defined + // (or create() throws for unimplemented types). + return provider!; + } + + /** Validate auth config against AgentCard's security requirements. */ + static validateAuthConfig( + authConfig: A2AAuthConfig | undefined, + securitySchemes: Record | undefined, + ): AuthValidationResult { + if (!securitySchemes || Object.keys(securitySchemes).length === 0) { + return { valid: true }; + } + + const requiredSchemes = Object.keys(securitySchemes); + + if (!authConfig) { + return { + valid: false, + diff: { + requiredSchemes, + configuredType: undefined, + missingConfig: ['Authentication is required but not configured'], + }, + }; + } + + const matchResult = A2AAuthProviderFactory.findMatchingScheme( + authConfig, + securitySchemes, + ); + + if (matchResult.matched) { + return { valid: true }; + } + + return { + valid: false, + diff: { + requiredSchemes, + configuredType: authConfig.type, + missingConfig: matchResult.missingConfig, + }, + }; + } + + // Security schemes have OR semantics per A2A spec - matching any single scheme is sufficient + private static findMatchingScheme( + authConfig: A2AAuthConfig, + securitySchemes: Record, + ): { matched: boolean; missingConfig: string[] } { + const missingConfig: string[] = []; + + for (const [schemeName, scheme] of Object.entries(securitySchemes)) { + switch (scheme.type) { + case 'apiKey': + if (authConfig.type === 'apiKey') { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires apiKey authentication`, + ); + break; + + case 'http': + if (authConfig.type === 'http') { + if ( + authConfig.scheme.toLowerCase() === scheme.scheme.toLowerCase() + ) { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication, but ${authConfig.scheme} was configured`, + ); + } else if ( + authConfig.type === 'google-credentials' && + scheme.scheme.toLowerCase() === 'bearer' + ) { + return { matched: true, missingConfig: [] }; + } else { + missingConfig.push( + `Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication`, + ); + } + break; + + case 'oauth2': + if (authConfig.type === 'oauth2') { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires OAuth 2.0 authentication`, + ); + break; + + case 'openIdConnect': + if (authConfig.type === 'openIdConnect') { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires OpenID Connect authentication`, + ); + break; + + case 'mutualTLS': + missingConfig.push( + `Scheme '${schemeName}' requires mTLS authentication (not yet supported)`, + ); + break; + + default: { + const _exhaustive: never = scheme; + missingConfig.push( + `Unknown security scheme type: ${(_exhaustive as SecurityScheme).type}`, + ); + } + } + } + + return { matched: false, missingConfig }; + } + + /** Get human-readable description of required auth for error messages. */ + static describeRequiredAuth( + securitySchemes: Record, + ): string { + const descriptions: string[] = []; + + for (const [name, scheme] of Object.entries(securitySchemes)) { + switch (scheme.type) { + case 'apiKey': + descriptions.push( + `API Key (${name}): Send ${scheme.name} in ${scheme.in}`, + ); + break; + case 'http': + descriptions.push(`HTTP ${scheme.scheme} (${name})`); + break; + case 'oauth2': + descriptions.push(`OAuth 2.0 (${name})`); + break; + case 'openIdConnect': + descriptions.push(`OpenID Connect (${name})`); + break; + case 'mutualTLS': + descriptions.push(`Mutual TLS (${name})`); + break; + default: { + const _exhaustive: never = scheme; + // This ensures TypeScript errors if a new SecurityScheme type is added + descriptions.push( + `Unknown (${name}): ${(_exhaustive as SecurityScheme).type}`, + ); + } + } + } + + return descriptions.join(' OR '); + } +} diff --git a/packages/core/src/agents/auth-provider/google-credentials-provider.test.ts b/packages/core/src/agents/auth-provider/google-credentials-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a9896bc43fb10842204c3f9a2158a8b41ea31d9 --- /dev/null +++ b/packages/core/src/agents/auth-provider/google-credentials-provider.test.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js'; +import type { GoogleCredentialsAuthConfig } from './types.js'; +import { GoogleAuth } from 'google-auth-library'; +import { OAuthUtils } from '../../mcp/oauth-utils.js'; + +// Mock the external dependencies +vi.mock('google-auth-library', () => ({ + GoogleAuth: vi.fn(), +})); + +describe('GoogleCredentialsAuthProvider', () => { + const mockConfig: GoogleCredentialsAuthConfig = { + type: 'google-credentials', + }; + + let mockGetClient: Mock; + let mockGetAccessToken: Mock; + let mockGetIdTokenClient: Mock; + let mockFetchIdToken: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGetAccessToken = vi + .fn() + .mockResolvedValue({ token: 'mock-access-token' }); + mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: mockGetAccessToken, + credentials: { expiry_date: Date.now() + 3600 * 1000 }, + }); + + mockFetchIdToken = vi.fn().mockResolvedValue('mock-id-token'); + mockGetIdTokenClient = vi.fn().mockResolvedValue({ + idTokenProvider: { + fetchIdToken: mockFetchIdToken, + }, + }); + + (GoogleAuth as unknown as Mock).mockImplementation(() => ({ + getClient: mockGetClient, + getIdTokenClient: mockGetIdTokenClient, + })); + }); + + describe('Initialization', () => { + it('throws if no targetUrl is provided', () => { + expect(() => new GoogleCredentialsAuthProvider(mockConfig)).toThrow( + /targetUrl must be provided/, + ); + }); + + it('throws if targetHost is not allowed', () => { + expect( + () => + new GoogleCredentialsAuthProvider(mockConfig, 'https://example.com'), + ).toThrow(/is not an allowed host/); + }); + + it('initializes seamlessly with .googleapis.com', () => { + expect( + () => + new GoogleCredentialsAuthProvider( + mockConfig, + 'https://language.googleapis.com/v1/models', + ), + ).not.toThrow(); + }); + + it('initializes seamlessly with .run.app', () => { + expect( + () => + new GoogleCredentialsAuthProvider( + mockConfig, + 'https://my-cloud-run-service.run.app', + ), + ).not.toThrow(); + }); + + it('throws if the protocol is not HTTPS', () => { + expect( + () => + new GoogleCredentialsAuthProvider( + mockConfig, + 'http://language.googleapis.com/v1/models', + ), + ).toThrow(/requires HTTPS/); + + expect( + () => + new GoogleCredentialsAuthProvider( + mockConfig, + 'http://my-cloud-run-service.run.app', + ), + ).toThrow(/requires HTTPS/); + }); + }); + + describe('Token Fetching', () => { + it('fetches an access token for googleapis.com endpoint', async () => { + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://language.googleapis.com', + ); + const headers = await provider.headers(); + + expect(headers).toEqual({ Authorization: 'Bearer mock-access-token' }); + expect(mockGetClient).toHaveBeenCalled(); + expect(mockGetAccessToken).toHaveBeenCalled(); + expect(mockGetIdTokenClient).not.toHaveBeenCalled(); + }); + + it('fetches an identity token for run.app endpoint', async () => { + // Mock OAuthUtils.parseTokenExpiry to avoid Base64 decoding issues in tests + vi.spyOn(OAuthUtils, 'parseTokenExpiry').mockReturnValue( + Date.now() + 1000000, + ); + + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://my-service.run.app/some-path', + ); + const headers = await provider.headers(); + + expect(headers).toEqual({ Authorization: 'Bearer mock-id-token' }); + expect(mockGetIdTokenClient).toHaveBeenCalledWith('my-service.run.app'); + expect(mockFetchIdToken).toHaveBeenCalledWith('my-service.run.app'); + expect(mockGetClient).not.toHaveBeenCalled(); + }); + + it('returns cached access token on subsequent calls', async () => { + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://language.googleapis.com', + ); + + await provider.headers(); + await provider.headers(); + + // Should only call getClient/getAccessToken once due to caching + expect(mockGetClient).toHaveBeenCalledTimes(1); + expect(mockGetAccessToken).toHaveBeenCalledTimes(1); + }); + + it('returns cached id token on subsequent calls', async () => { + vi.spyOn(OAuthUtils, 'parseTokenExpiry').mockReturnValue( + Date.now() + 1000000, + ); + + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://my-service.run.app', + ); + + await provider.headers(); + await provider.headers(); + + expect(mockGetIdTokenClient).toHaveBeenCalledTimes(1); + expect(mockFetchIdToken).toHaveBeenCalledTimes(1); + }); + + it('re-fetches access token on 401 (shouldRetryWithHeaders)', async () => { + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://language.googleapis.com', + ); + + // Prime the cache + await provider.headers(); + expect(mockGetAccessToken).toHaveBeenCalledTimes(1); + + const req = {} as RequestInit; + const res = { status: 401 } as Response; + + const retryHeaders = await provider.shouldRetryWithHeaders(req, res); + + expect(retryHeaders).toEqual({ + Authorization: 'Bearer mock-access-token', + }); + // Cache was cleared, so getAccessToken was called again + expect(mockGetAccessToken).toHaveBeenCalledTimes(2); + }); + + it('re-fetches token on 403', async () => { + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://language.googleapis.com', + ); + + const req = {} as RequestInit; + const res = { status: 403 } as Response; + + const retryHeaders = await provider.shouldRetryWithHeaders(req, res); + + expect(retryHeaders).toEqual({ + Authorization: 'Bearer mock-access-token', + }); + }); + + it('stops retrying after MAX_AUTH_RETRIES', async () => { + const provider = new GoogleCredentialsAuthProvider( + mockConfig, + 'https://language.googleapis.com', + ); + + const req = {} as RequestInit; + const res = { status: 401 } as Response; + + // First two retries should succeed (MAX_AUTH_RETRIES = 2) + expect(await provider.shouldRetryWithHeaders(req, res)).toBeDefined(); + expect(await provider.shouldRetryWithHeaders(req, res)).toBeDefined(); + + // Third should return undefined (exhausted) + expect(await provider.shouldRetryWithHeaders(req, res)).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/google-credentials-provider.ts b/packages/core/src/agents/auth-provider/google-credentials-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff9ef8fc3dc5ea323548ffdd44955616ea8af1a9 --- /dev/null +++ b/packages/core/src/agents/auth-provider/google-credentials-provider.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { GoogleCredentialsAuthConfig } from './types.js'; +import { GoogleAuth } from 'google-auth-library'; +import { debugLogger } from '../../utils/debugLogger.js'; +import { OAuthUtils, FIVE_MIN_BUFFER_MS } from '../../mcp/oauth-utils.js'; + +const CLOUD_RUN_HOST_REGEX = /^(.*\.)?run\.app$/; +const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, CLOUD_RUN_HOST_REGEX]; + +/** + * Authentication provider for Google ADC (Application Default Credentials). + * Automatically decides whether to use identity tokens or access tokens + * based on the target endpoint URL. + */ +export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider { + readonly type = 'google-credentials'; + + private readonly auth: GoogleAuth; + private readonly useIdToken: boolean = false; + private readonly audience?: string; + private cachedToken?: string; + private tokenExpiryTime?: number; + + constructor( + private readonly config: GoogleCredentialsAuthConfig, + targetUrl?: string, + ) { + super(); + + if (!targetUrl) { + throw new Error( + 'targetUrl must be provided to GoogleCredentialsAuthProvider to determine token audience.', + ); + } + + const urlObj = new URL(targetUrl); + if (urlObj.protocol !== 'https:') { + throw new Error( + `Protocol "${urlObj.protocol}" is not secure. Google Credential provider requires HTTPS.`, + ); + } + + const hostname = urlObj.hostname; + const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname); + + if (isRunAppHost) { + this.useIdToken = true; + } + this.audience = hostname; + + if ( + !this.useIdToken && + !ALLOWED_HOSTS.some((pattern) => pattern.test(hostname)) + ) { + throw new Error( + `Host "${hostname}" is not an allowed host for Google Credential provider.`, + ); + } + + // A2A spec requires scopes if configured, otherwise use default cloud-platform + const scopes = + this.config.scopes && this.config.scopes.length > 0 + ? this.config.scopes + : ['https://www.googleapis.com/auth/cloud-platform']; + + this.auth = new GoogleAuth({ + scopes, + }); + } + + override async initialize(): Promise { + // We can pre-fetch or validate if necessary here, + // but deferred fetching is usually better for auth tokens. + } + + async headers(): Promise { + // Check cache + if ( + this.cachedToken && + this.tokenExpiryTime && + Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS + ) { + return { Authorization: `Bearer ${this.cachedToken}` }; + } + + // Clear expired cache + this.cachedToken = undefined; + this.tokenExpiryTime = undefined; + + if (this.useIdToken) { + try { + const idClient = await this.auth.getIdTokenClient(this.audience!); + const idToken = await idClient.idTokenProvider.fetchIdToken( + this.audience!, + ); + + const expiryTime = OAuthUtils.parseTokenExpiry(idToken); + if (expiryTime) { + this.tokenExpiryTime = expiryTime; + this.cachedToken = idToken; + } + + return { Authorization: `Bearer ${idToken}` }; + } catch (e) { + const errorMessage = `Failed to get ADC ID token: ${ + e instanceof Error ? e.message : String(e) + }`; + debugLogger.error(errorMessage, e); + throw new Error(errorMessage); + } + } + + // Otherwise, access token + try { + const client = await this.auth.getClient(); + const token = await client.getAccessToken(); + + if (token.token) { + this.cachedToken = token.token; + // Use expiry_date from the underlying credentials if available. + const creds = client.credentials; + if (creds.expiry_date) { + this.tokenExpiryTime = creds.expiry_date; + } + return { Authorization: `Bearer ${token.token}` }; + } + throw new Error('Failed to retrieve ADC access token.'); + } catch (e) { + const errorMessage = `Failed to get ADC access token: ${ + e instanceof Error ? e.message : String(e) + }`; + debugLogger.error(errorMessage, e); + throw new Error(errorMessage); + } + } + + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + this.authRetryCount = 0; + return undefined; + } + + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + this.authRetryCount++; + + debugLogger.debug( + '[GoogleCredentialsAuthProvider] Re-fetching token after auth failure', + ); + + // Clear cache to force a re-fetch + this.cachedToken = undefined; + this.tokenExpiryTime = undefined; + + return this.headers(); + } +} diff --git a/packages/core/src/agents/auth-provider/http-provider.test.ts b/packages/core/src/agents/auth-provider/http-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e56dcb839d0b7157f1a866e477dae5fae571b1e2 --- /dev/null +++ b/packages/core/src/agents/auth-provider/http-provider.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HttpAuthProvider } from './http-provider.js'; + +describe('HttpAuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Bearer Authentication', () => { + it('should provide Bearer token header', async () => { + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'test-token', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer test-token' }); + }); + + it('should resolve token from environment variable', async () => { + process.env['TEST_TOKEN'] = 'env-token'; + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: '$TEST_TOKEN', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer env-token' }); + delete process.env['TEST_TOKEN']; + }); + }); + + describe('Basic Authentication', () => { + it('should provide Basic auth header', async () => { + const config = { + type: 'http' as const, + scheme: 'Basic' as const, + username: 'user', + password: 'password', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + const expected = Buffer.from('user:password').toString('base64'); + expect(headers).toEqual({ Authorization: `Basic ${expected}` }); + }); + }); + + describe('Generic/Raw Authentication', () => { + it('should provide custom scheme with raw value', async () => { + const config = { + type: 'http' as const, + scheme: 'CustomScheme', + value: 'raw-value-here', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'CustomScheme raw-value-here' }); + }); + + it('should support Digest via raw value', async () => { + const config = { + type: 'http' as const, + scheme: 'Digest', + value: 'username="foo", response="bar"', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ + Authorization: 'Digest username="foo", response="bar"', + }); + }); + }); + + describe('Retry logic', () => { + it('should re-initialize on 401 for Bearer', async () => { + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: '$DYNAMIC_TOKEN', + }; + process.env['DYNAMIC_TOKEN'] = 'first'; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + process.env['DYNAMIC_TOKEN'] = 'second'; + const mockResponse = { status: 401 } as Response; + const retryHeaders = await provider.shouldRetryWithHeaders( + {}, + mockResponse, + ); + + expect(retryHeaders).toEqual({ Authorization: 'Bearer second' }); + delete process.env['DYNAMIC_TOKEN']; + }); + + it('should stop after max retries', async () => { + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'token', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const mockResponse = { status: 401 } as Response; + + // MAX_AUTH_RETRIES is 2 + await provider.shouldRetryWithHeaders({}, mockResponse); + await provider.shouldRetryWithHeaders({}, mockResponse); + const third = await provider.shouldRetryWithHeaders({}, mockResponse); + + expect(third).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/http-provider.ts b/packages/core/src/agents/auth-provider/http-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1af8a88e481102efaf4f1da347fba40236bbc59 --- /dev/null +++ b/packages/core/src/agents/auth-provider/http-provider.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { HttpAuthConfig } from './types.js'; +import { resolveAuthValue } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Authentication provider for HTTP authentication schemes. + * Supports Bearer, Basic, and any IANA-registered scheme via raw value. + */ +export class HttpAuthProvider extends BaseA2AAuthProvider { + readonly type = 'http'; + + private resolvedToken?: string; + private resolvedUsername?: string; + private resolvedPassword?: string; + private resolvedValue?: string; + + constructor(private readonly config: HttpAuthConfig) { + super(); + } + + override async initialize(): Promise { + const config = this.config; + if ('token' in config) { + this.resolvedToken = await resolveAuthValue(config.token); + } else if ('username' in config) { + this.resolvedUsername = await resolveAuthValue(config.username); + this.resolvedPassword = await resolveAuthValue(config.password); + } else { + // Generic raw value for any other IANA-registered scheme + this.resolvedValue = await resolveAuthValue(config.value); + } + debugLogger.debug( + `[HttpAuthProvider] Initialized with scheme: ${this.config.scheme}`, + ); + } + + override async headers(): Promise { + const config = this.config; + if ('token' in config) { + if (!this.resolvedToken) + throw new Error('HttpAuthProvider not initialized'); + return { Authorization: `Bearer ${this.resolvedToken}` }; + } + + if ('username' in config) { + if (!this.resolvedUsername || !this.resolvedPassword) { + throw new Error('HttpAuthProvider not initialized'); + } + const credentials = Buffer.from( + `${this.resolvedUsername}:${this.resolvedPassword}`, + ).toString('base64'); + return { Authorization: `Basic ${credentials}` }; + } + + // Generic raw value for any other IANA-registered scheme + if (!this.resolvedValue) + throw new Error('HttpAuthProvider not initialized'); + return { Authorization: `${config.scheme} ${this.resolvedValue}` }; + } + + /** + * Re-resolves credentials on auth failure (e.g. rotated tokens via $ENV or !command). + * Respects MAX_AUTH_RETRIES from the base class to prevent infinite loops. + */ + override async shouldRetryWithHeaders( + req: RequestInit, + res: Response, + ): Promise { + if (res.status === 401 || res.status === 403) { + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + debugLogger.debug( + '[HttpAuthProvider] Re-resolving values after auth failure', + ); + await this.initialize(); + } + return super.shouldRetryWithHeaders(req, res); + } +} diff --git a/packages/core/src/agents/auth-provider/oauth2-provider.test.ts b/packages/core/src/agents/auth-provider/oauth2-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a40b242d41f423834ffd4fbf60e91380cc19c190 --- /dev/null +++ b/packages/core/src/agents/auth-provider/oauth2-provider.test.ts @@ -0,0 +1,651 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OAuth2AuthProvider } from './oauth2-provider.js'; +import type { OAuth2AuthConfig } from './types.js'; +import type { AgentCard } from '@a2a-js/sdk'; + +// Mock DefaultAgentCardResolver from @a2a-js/sdk/client. +const mockResolve = vi.fn(); +vi.mock('@a2a-js/sdk/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DefaultAgentCardResolver: vi.fn().mockImplementation(() => ({ + resolve: mockResolve, + })), + }; +}); + +// Mock all external dependencies. +vi.mock('../../mcp/oauth-token-storage.js', () => { + const MCPOAuthTokenStorage = vi.fn().mockImplementation(() => ({ + getCredentials: vi.fn().mockResolvedValue(null), + saveToken: vi.fn().mockResolvedValue(undefined), + deleteCredentials: vi.fn().mockResolvedValue(undefined), + isTokenExpired: vi.fn().mockReturnValue(false), + })); + return { MCPOAuthTokenStorage }; +}); + +vi.mock('../../utils/oauth-flow.js', () => ({ + generatePKCEParams: vi.fn().mockReturnValue({ + codeVerifier: 'test-verifier', + codeChallenge: 'test-challenge', + state: 'test-state', + }), + startCallbackServer: vi.fn().mockReturnValue({ + port: Promise.resolve(12345), + response: Promise.resolve({ code: 'test-code', state: 'test-state' }), + }), + getPortFromUrl: vi.fn().mockReturnValue(undefined), + buildAuthorizationUrl: vi + .fn() + .mockReturnValue('https://auth.example.com/authorize?foo=bar'), + exchangeCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }), + refreshAccessToken: vi.fn().mockResolvedValue({ + access_token: 'refreshed-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refreshed-refresh-token', + }), +})); + +vi.mock('../../utils/secure-browser-launcher.js', () => ({ + openBrowserSecurely: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../utils/authConsent.js', () => ({ + getConsentForOauth: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../../utils/events.js', () => ({ + coreEvents: { + emitFeedback: vi.fn(), + }, +})); + +vi.mock('../../utils/debugLogger.js', () => ({ + debugLogger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + }, +})); + +// Re-import mocked modules for assertions. +const { MCPOAuthTokenStorage } = await import( + '../../mcp/oauth-token-storage.js' +); +const { + refreshAccessToken, + exchangeCodeForToken, + generatePKCEParams, + startCallbackServer, + buildAuthorizationUrl, +} = await import('../../utils/oauth-flow.js'); +const { getConsentForOauth } = await import('../../utils/authConsent.js'); + +function createConfig( + overrides: Partial = {}, +): OAuth2AuthConfig { + return { + type: 'oauth2', + client_id: 'test-client-id', + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + scopes: ['read', 'write'], + ...overrides, + }; +} + +function getTokenStorage() { + // Access the mocked MCPOAuthTokenStorage instance created in the constructor. + const instance = vi.mocked(MCPOAuthTokenStorage).mock.results.at(-1)!.value; + return instance as { + getCredentials: ReturnType; + saveToken: ReturnType; + deleteCredentials: ReturnType; + isTokenExpired: ReturnType; + }; +} + +describe('OAuth2AuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should set type to oauth2', () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + expect(provider.type).toBe('oauth2'); + }); + + it('should use config values for authorization_url and token_url', () => { + const config = createConfig({ + authorization_url: 'https://custom.example.com/authorize', + token_url: 'https://custom.example.com/token', + }); + const provider = new OAuth2AuthProvider(config, 'test-agent'); + // Verify by calling headers which will trigger interactive flow with these URLs. + expect(provider.type).toBe('oauth2'); + }); + + it('should merge agent card defaults when config values are missing', () => { + const config = createConfig({ + authorization_url: undefined, + token_url: undefined, + scopes: undefined, + }); + + const agentCard = { + securitySchemes: { + oauth: { + type: 'oauth2' as const, + flows: { + authorizationCode: { + authorizationUrl: 'https://card.example.com/authorize', + tokenUrl: 'https://card.example.com/token', + scopes: { read: 'Read access', write: 'Write access' }, + }, + }, + }, + }, + } as unknown as AgentCard; + + const provider = new OAuth2AuthProvider(config, 'test-agent', agentCard); + expect(provider.type).toBe('oauth2'); + }); + + it('should prefer config values over agent card values', async () => { + const config = createConfig({ + authorization_url: 'https://config.example.com/authorize', + token_url: 'https://config.example.com/token', + scopes: ['custom-scope'], + }); + + const agentCard = { + securitySchemes: { + oauth: { + type: 'oauth2' as const, + flows: { + authorizationCode: { + authorizationUrl: 'https://card.example.com/authorize', + tokenUrl: 'https://card.example.com/token', + scopes: { read: 'Read access' }, + }, + }, + }, + }, + } as unknown as AgentCard; + + const provider = new OAuth2AuthProvider(config, 'test-agent', agentCard); + await provider.headers(); + + // The config URLs should be used, not the agent card ones. + expect(vi.mocked(buildAuthorizationUrl)).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl: 'https://config.example.com/authorize', + tokenUrl: 'https://config.example.com/token', + scopes: ['custom-scope'], + }), + expect.anything(), + expect.anything(), + undefined, + ); + }); + }); + + describe('initialize', () => { + it('should load a valid token from storage', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { + accessToken: 'stored-token', + tokenType: 'Bearer', + }, + updatedAt: Date.now(), + }); + storage.isTokenExpired.mockReturnValue(false); + + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer stored-token' }); + }); + + it('should not cache an expired token from storage', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { + accessToken: 'expired-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1000, + }, + updatedAt: Date.now(), + }); + storage.isTokenExpired.mockReturnValue(true); + + await provider.initialize(); + + // Should trigger interactive flow since cached token is null. + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer new-access-token' }); + }); + + it('should handle no stored credentials gracefully', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue(null); + + await provider.initialize(); + + // Should trigger interactive flow. + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer new-access-token' }); + }); + }); + + describe('headers', () => { + it('should return cached token if valid', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { accessToken: 'cached-token', tokenType: 'Bearer' }, + updatedAt: Date.now(), + }); + storage.isTokenExpired.mockReturnValue(false); + + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer cached-token' }); + expect(vi.mocked(exchangeCodeForToken)).not.toHaveBeenCalled(); + expect(vi.mocked(refreshAccessToken)).not.toHaveBeenCalled(); + }); + + it('should refresh token when expired with refresh_token available', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + // First call: load from storage (expired but with refresh token). + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { + accessToken: 'expired-token', + tokenType: 'Bearer', + refreshToken: 'my-refresh-token', + expiresAt: Date.now() - 1000, + }, + updatedAt: Date.now(), + }); + // isTokenExpired: false for initialize (to cache it), true for headers check. + storage.isTokenExpired + .mockReturnValueOnce(false) // initialize: cache the token + .mockReturnValueOnce(true); // headers: token is expired + + await provider.initialize(); + const headers = await provider.headers(); + + expect(vi.mocked(refreshAccessToken)).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'test-client-id' }), + 'my-refresh-token', + 'https://auth.example.com/token', + ); + expect(headers).toEqual({ + Authorization: 'Bearer refreshed-access-token', + }); + expect(storage.saveToken).toHaveBeenCalled(); + }); + + it('should fall back to interactive flow when refresh fails', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { + accessToken: 'expired-token', + tokenType: 'Bearer', + refreshToken: 'bad-refresh-token', + expiresAt: Date.now() - 1000, + }, + updatedAt: Date.now(), + }); + storage.isTokenExpired + .mockReturnValueOnce(false) // initialize + .mockReturnValueOnce(true); // headers + + vi.mocked(refreshAccessToken).mockRejectedValueOnce( + new Error('Refresh failed'), + ); + + await provider.initialize(); + const headers = await provider.headers(); + + // Should have deleted stale credentials and done interactive flow. + expect(storage.deleteCredentials).toHaveBeenCalledWith('test-agent'); + expect(headers).toEqual({ Authorization: 'Bearer new-access-token' }); + }); + + it('should trigger interactive flow when no token exists', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue(null); + + await provider.initialize(); + const headers = await provider.headers(); + + expect(vi.mocked(generatePKCEParams)).toHaveBeenCalled(); + expect(vi.mocked(startCallbackServer)).toHaveBeenCalled(); + expect(vi.mocked(exchangeCodeForToken)).toHaveBeenCalled(); + expect(storage.saveToken).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ accessToken: 'new-access-token' }), + 'test-client-id', + 'https://auth.example.com/token', + ); + expect(headers).toEqual({ Authorization: 'Bearer new-access-token' }); + }); + + it('should throw when user declines consent', async () => { + vi.mocked(getConsentForOauth).mockResolvedValueOnce(false); + + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + await provider.initialize(); + + await expect(provider.headers()).rejects.toThrow( + 'Authentication cancelled by user', + ); + }); + + it('should throw when client_id is missing', async () => { + const config = createConfig({ client_id: undefined }); + const provider = new OAuth2AuthProvider(config, 'test-agent'); + await provider.initialize(); + + await expect(provider.headers()).rejects.toThrow(/requires a client_id/); + }); + + it('should throw when authorization_url and token_url are missing', async () => { + const config = createConfig({ + authorization_url: undefined, + token_url: undefined, + }); + const provider = new OAuth2AuthProvider(config, 'test-agent'); + await provider.initialize(); + + await expect(provider.headers()).rejects.toThrow( + /requires authorization_url and token_url/, + ); + }); + }); + + describe('shouldRetryWithHeaders', () => { + it('should clear token and re-authenticate on 401', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { accessToken: 'old-token', tokenType: 'Bearer' }, + updatedAt: Date.now(), + }); + storage.isTokenExpired.mockReturnValue(false); + + await provider.initialize(); + + const res = new Response(null, { status: 401 }); + const retryHeaders = await provider.shouldRetryWithHeaders({}, res); + + expect(storage.deleteCredentials).toHaveBeenCalledWith('test-agent'); + expect(retryHeaders).toBeDefined(); + expect(retryHeaders).toHaveProperty('Authorization'); + }); + + it('should clear token and re-authenticate on 403', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { accessToken: 'old-token', tokenType: 'Bearer' }, + updatedAt: Date.now(), + }); + storage.isTokenExpired.mockReturnValue(false); + + await provider.initialize(); + + const res = new Response(null, { status: 403 }); + const retryHeaders = await provider.shouldRetryWithHeaders({}, res); + + expect(retryHeaders).toBeDefined(); + }); + + it('should return undefined for non-auth errors', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + + const res = new Response(null, { status: 500 }); + const retryHeaders = await provider.shouldRetryWithHeaders({}, res); + + expect(retryHeaders).toBeUndefined(); + }); + + it('should respect MAX_AUTH_RETRIES', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + + const res401 = new Response(null, { status: 401 }); + + // First retry β€” should succeed. + const first = await provider.shouldRetryWithHeaders({}, res401); + expect(first).toBeDefined(); + + // Second retry β€” should succeed. + const second = await provider.shouldRetryWithHeaders({}, res401); + expect(second).toBeDefined(); + + // Third retry β€” should be blocked. + const third = await provider.shouldRetryWithHeaders({}, res401); + expect(third).toBeUndefined(); + }); + + it('should reset retry count on non-auth response', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + + const res401 = new Response(null, { status: 401 }); + const res200 = new Response(null, { status: 200 }); + + await provider.shouldRetryWithHeaders({}, res401); + await provider.shouldRetryWithHeaders({}, res200); // resets + + // Should be able to retry again. + const result = await provider.shouldRetryWithHeaders({}, res401); + expect(result).toBeDefined(); + }); + }); + + describe('token persistence', () => { + it('should persist token after successful interactive auth', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + await provider.initialize(); + await provider.headers(); + + expect(storage.saveToken).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ + accessToken: 'new-access-token', + tokenType: 'Bearer', + refreshToken: 'new-refresh-token', + }), + 'test-client-id', + 'https://auth.example.com/token', + ); + }); + + it('should persist token after successful refresh', async () => { + const provider = new OAuth2AuthProvider(createConfig(), 'test-agent'); + const storage = getTokenStorage(); + + storage.getCredentials.mockResolvedValue({ + serverName: 'test-agent', + token: { + accessToken: 'expired-token', + tokenType: 'Bearer', + refreshToken: 'my-refresh-token', + }, + updatedAt: Date.now(), + }); + storage.isTokenExpired + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await provider.initialize(); + await provider.headers(); + + expect(storage.saveToken).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ + accessToken: 'refreshed-access-token', + }), + 'test-client-id', + 'https://auth.example.com/token', + ); + }); + }); + + describe('agent card integration', () => { + it('should discover URLs from agent card when not in config', async () => { + const config = createConfig({ + authorization_url: undefined, + token_url: undefined, + scopes: undefined, + }); + + const agentCard = { + securitySchemes: { + myOauth: { + type: 'oauth2' as const, + flows: { + authorizationCode: { + authorizationUrl: 'https://card.example.com/auth', + tokenUrl: 'https://card.example.com/token', + scopes: { profile: 'View profile', email: 'View email' }, + }, + }, + }, + }, + } as unknown as AgentCard; + + const provider = new OAuth2AuthProvider(config, 'card-agent', agentCard); + await provider.initialize(); + await provider.headers(); + + expect(vi.mocked(buildAuthorizationUrl)).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl: 'https://card.example.com/auth', + tokenUrl: 'https://card.example.com/token', + scopes: ['profile', 'email'], + }), + expect.anything(), + expect.anything(), + undefined, + ); + }); + + it('should discover URLs from agentCardUrl via DefaultAgentCardResolver during initialize', async () => { + const config = createConfig({ + authorization_url: undefined, + token_url: undefined, + scopes: undefined, + }); + + // Simulate a normalized agent card returned by DefaultAgentCardResolver. + mockResolve.mockResolvedValue({ + securitySchemes: { + myOauth: { + type: 'oauth2' as const, + flows: { + authorizationCode: { + authorizationUrl: 'https://discovered.example.com/auth', + tokenUrl: 'https://discovered.example.com/token', + scopes: { openid: 'OpenID', profile: 'Profile' }, + }, + }, + }, + }, + } as unknown as AgentCard); + + // No agentCard passed to constructor β€” only agentCardUrl. + const provider = new OAuth2AuthProvider( + config, + 'discover-agent', + undefined, + 'https://example.com/.well-known/agent-card.json', + ); + await provider.initialize(); + await provider.headers(); + + expect(mockResolve).toHaveBeenCalledWith( + 'https://example.com/.well-known/agent-card.json', + '', + ); + expect(vi.mocked(buildAuthorizationUrl)).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl: 'https://discovered.example.com/auth', + tokenUrl: 'https://discovered.example.com/token', + scopes: ['openid', 'profile'], + }), + expect.anything(), + expect.anything(), + undefined, + ); + }); + + it('should ignore agent card with no authorizationCode flow', () => { + const config = createConfig({ + authorization_url: undefined, + token_url: undefined, + }); + + const agentCard = { + securitySchemes: { + myOauth: { + type: 'oauth2' as const, + flows: { + clientCredentials: { + tokenUrl: 'https://card.example.com/token', + scopes: {}, + }, + }, + }, + }, + } as unknown as AgentCard; + + // Should not throw β€” just won't have URLs. + const provider = new OAuth2AuthProvider(config, 'card-agent', agentCard); + expect(provider.type).toBe('oauth2'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/oauth2-provider.ts b/packages/core/src/agents/auth-provider/oauth2-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c93a5b63dbc233660e0e24fcf41700caa9362f0d --- /dev/null +++ b/packages/core/src/agents/auth-provider/oauth2-provider.ts @@ -0,0 +1,340 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type HttpHeaders, DefaultAgentCardResolver } from '@a2a-js/sdk/client'; +import type { AgentCard } from '@a2a-js/sdk'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { OAuth2AuthConfig } from './types.js'; +import { MCPOAuthTokenStorage } from '../../mcp/oauth-token-storage.js'; +import type { OAuthToken } from '../../mcp/token-storage/types.js'; +import { + generatePKCEParams, + startCallbackServer, + getPortFromUrl, + buildAuthorizationUrl, + exchangeCodeForToken, + refreshAccessToken, + type OAuthFlowConfig, +} from '../../utils/oauth-flow.js'; +import { openBrowserSecurely } from '../../utils/secure-browser-launcher.js'; +import { getConsentForOauth } from '../../utils/authConsent.js'; +import { FatalCancellationError, getErrorMessage } from '../../utils/errors.js'; +import { coreEvents } from '../../utils/events.js'; +import { debugLogger } from '../../utils/debugLogger.js'; +import { Storage } from '../../config/storage.js'; + +/** + * Authentication provider for OAuth 2.0 Authorization Code flow with PKCE. + * + * Used by A2A remote agents whose security scheme is `oauth2`. + * Reuses the shared OAuth flow primitives from `utils/oauth-flow.ts` + * and persists tokens via `MCPOAuthTokenStorage`. + */ +export class OAuth2AuthProvider extends BaseA2AAuthProvider { + readonly type = 'oauth2'; + + private readonly tokenStorage: MCPOAuthTokenStorage; + private cachedToken: OAuthToken | null = null; + + /** Resolved OAuth URLs β€” may come from config or agent card. */ + private authorizationUrl: string | undefined; + private tokenUrl: string | undefined; + private scopes: string[] | undefined; + + constructor( + private readonly config: OAuth2AuthConfig, + private readonly agentName: string, + agentCard?: AgentCard, + private readonly agentCardUrl?: string, + ) { + super(); + this.tokenStorage = new MCPOAuthTokenStorage( + Storage.getA2AOAuthTokensPath(), + 'gemini-cli-a2a', + ); + + // Seed from user config. + this.authorizationUrl = config.authorization_url; + this.tokenUrl = config.token_url; + this.scopes = config.scopes; + + // Fall back to agent card's OAuth2 security scheme if user config is incomplete. + this.mergeAgentCardDefaults(agentCard); + } + + /** + * Initialize the provider by loading any persisted token from storage. + * Also discovers OAuth URLs from the agent card if not yet resolved. + */ + override async initialize(): Promise { + // If OAuth URLs are still missing, fetch the agent card to discover them. + if ((!this.authorizationUrl || !this.tokenUrl) && this.agentCardUrl) { + await this.fetchAgentCardDefaults(); + } + + const credentials = await this.tokenStorage.getCredentials(this.agentName); + if (credentials && !this.tokenStorage.isTokenExpired(credentials.token)) { + this.cachedToken = credentials.token; + debugLogger.debug( + `[OAuth2AuthProvider] Loaded valid cached token for "${this.agentName}"`, + ); + } + } + + /** + * Return an Authorization header with a valid Bearer token. + * Refreshes or triggers interactive auth as needed. + */ + override async headers(): Promise { + // 1. Valid cached token β†’ return immediately. + if ( + this.cachedToken && + !this.tokenStorage.isTokenExpired(this.cachedToken) + ) { + return { Authorization: `Bearer ${this.cachedToken.accessToken}` }; + } + + // 2. Expired but has refresh token β†’ attempt silent refresh. + if ( + this.cachedToken?.refreshToken && + this.tokenUrl && + this.config.client_id + ) { + try { + const refreshed = await refreshAccessToken( + { + clientId: this.config.client_id, + clientSecret: this.config.client_secret, + scopes: this.scopes, + }, + this.cachedToken.refreshToken, + this.tokenUrl, + ); + + this.cachedToken = this.toOAuthToken( + refreshed, + this.cachedToken.refreshToken, + ); + await this.persistToken(); + return { Authorization: `Bearer ${this.cachedToken.accessToken}` }; + } catch (error) { + debugLogger.debug( + `[OAuth2AuthProvider] Refresh failed, falling back to interactive flow: ${getErrorMessage(error)}`, + ); + // Clear stale credentials and fall through to interactive flow. + await this.tokenStorage.deleteCredentials(this.agentName); + } + } + + // 3. No valid token β†’ interactive browser-based auth. + this.cachedToken = await this.authenticateInteractively(); + return { Authorization: `Bearer ${this.cachedToken.accessToken}` }; + } + + /** + * On 401/403, clear the cached token and re-authenticate (up to MAX_AUTH_RETRIES). + */ + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + this.authRetryCount = 0; + return undefined; + } + + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + this.authRetryCount++; + + debugLogger.debug( + '[OAuth2AuthProvider] Auth failure, clearing token and re-authenticating', + ); + this.cachedToken = null; + await this.tokenStorage.deleteCredentials(this.agentName); + + return this.headers(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Merge authorization_url, token_url, and scopes from the agent card's + * `securitySchemes` when not already provided via user config. + */ + private mergeAgentCardDefaults( + agentCard?: Pick | null, + ): void { + if (!agentCard?.securitySchemes) return; + + for (const scheme of Object.values(agentCard.securitySchemes)) { + if (scheme.type === 'oauth2' && scheme.flows.authorizationCode) { + const flow = scheme.flows.authorizationCode; + this.authorizationUrl ??= flow.authorizationUrl; + this.tokenUrl ??= flow.tokenUrl; + this.scopes ??= Object.keys(flow.scopes); + break; // Use the first matching scheme. + } + } + } + + /** + * Fetch the agent card from `agentCardUrl` using `DefaultAgentCardResolver` + * (which normalizes proto-format cards) and extract OAuth2 URLs. + */ + private async fetchAgentCardDefaults(): Promise { + if (!this.agentCardUrl) return; + + try { + debugLogger.debug( + `[OAuth2AuthProvider] Fetching agent card from ${this.agentCardUrl}`, + ); + const resolver = new DefaultAgentCardResolver(); + const card = await resolver.resolve(this.agentCardUrl, ''); + this.mergeAgentCardDefaults(card); + } catch (error) { + debugLogger.warn( + `[OAuth2AuthProvider] Could not fetch agent card for OAuth URL discovery: ${getErrorMessage(error)}`, + ); + } + } + + /** + * Run a full OAuth 2.0 Authorization Code + PKCE flow through the browser. + */ + private async authenticateInteractively(): Promise { + if (!this.config.client_id) { + throw new Error( + `OAuth2 authentication for agent "${this.agentName}" requires a client_id. ` + + 'Add client_id to the auth config in your agent definition.', + ); + } + if (!this.authorizationUrl || !this.tokenUrl) { + throw new Error( + `OAuth2 authentication for agent "${this.agentName}" requires authorization_url and token_url. ` + + 'Provide them in the auth config or ensure the agent card exposes an oauth2 security scheme.', + ); + } + + const flowConfig: OAuthFlowConfig = { + clientId: this.config.client_id, + clientSecret: this.config.client_secret, + authorizationUrl: this.authorizationUrl, + tokenUrl: this.tokenUrl, + scopes: this.scopes, + }; + + const pkceParams = generatePKCEParams(); + const preferredPort = getPortFromUrl(flowConfig.redirectUri); + const callbackServer = startCallbackServer(pkceParams.state, preferredPort); + const redirectPort = await callbackServer.port; + + const authUrl = buildAuthorizationUrl( + flowConfig, + pkceParams, + redirectPort, + /* resource= */ undefined, // No MCP resource parameter for A2A. + ); + + const consent = await getConsentForOauth( + `Authentication required for A2A agent: '${this.agentName}'.`, + ); + if (!consent) { + throw new FatalCancellationError('Authentication cancelled by user.'); + } + + coreEvents.emitFeedback( + 'info', + `β†’ Opening your browser for OAuth sign-in... + +` + + `If the browser does not open, copy and paste this URL into your browser: +` + + `${authUrl} + +` + + `πŸ’‘ TIP: Triple-click to select the entire URL, then copy and paste it into your browser. +` + + `⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.`, + ); + + try { + await openBrowserSecurely(authUrl); + } catch (error) { + debugLogger.warn( + 'Failed to open browser automatically:', + getErrorMessage(error), + ); + } + + const { code } = await callbackServer.response; + debugLogger.debug( + 'βœ“ Authorization code received, exchanging for tokens...', + ); + + const tokenResponse = await exchangeCodeForToken( + flowConfig, + code, + pkceParams.codeVerifier, + redirectPort, + /* resource= */ undefined, + ); + + if (!tokenResponse.access_token) { + throw new Error('No access token received from token endpoint'); + } + + const token = this.toOAuthToken(tokenResponse); + this.cachedToken = token; + await this.persistToken(); + + debugLogger.debug('βœ“ OAuth2 authentication successful! Token saved.'); + return token; + } + + /** + * Convert an `OAuthTokenResponse` into the internal `OAuthToken` format. + */ + private toOAuthToken( + response: { + access_token: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + }, + fallbackRefreshToken?: string, + ): OAuthToken { + const token: OAuthToken = { + accessToken: response.access_token, + tokenType: response.token_type || 'Bearer', + refreshToken: response.refresh_token || fallbackRefreshToken, + scope: response.scope, + }; + + if (response.expires_in) { + token.expiresAt = Date.now() + response.expires_in * 1000; + } + + return token; + } + + /** + * Persist the current cached token to disk. + */ + private async persistToken(): Promise { + if (!this.cachedToken) return; + await this.tokenStorage.saveToken( + this.agentName, + this.cachedToken, + this.config.client_id, + this.tokenUrl, + ); + } +} diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ba3bbbd43e2c91225b66621360ca84f96c5ab60 --- /dev/null +++ b/packages/core/src/agents/auth-provider/types.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Client-side auth configuration for A2A remote agents. + * Corresponds to server-side SecurityScheme types from @a2a-js/sdk. + * @see https://a2a-protocol.org/latest/specification/#451-securityscheme + */ + +import type { AuthenticationHandler } from '@a2a-js/sdk/client'; + +export type A2AAuthProviderType = + | 'google-credentials' + | 'apiKey' + | 'http' + | 'oauth2' + | 'openIdConnect'; + +export interface A2AAuthProvider extends AuthenticationHandler { + readonly type: A2AAuthProviderType; + initialize?(): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BaseAuthConfig {} + +/** Client config for google-credentials (not in A2A spec, Gemini-specific). */ +export interface GoogleCredentialsAuthConfig extends BaseAuthConfig { + type: 'google-credentials'; + scopes?: string[]; +} + +/** Client config corresponding to APIKeySecurityScheme. Only header location is supported. */ +// TODO: Add 'query' and 'cookie' location support if needed. +export interface ApiKeyAuthConfig extends BaseAuthConfig { + type: 'apiKey'; + /** The secret. Supports $ENV_VAR, !command, or literal. */ + key: string; + /** Header name. @default 'X-API-Key' */ + name?: string; +} + +/** Client config corresponding to HTTPAuthSecurityScheme. */ +export type HttpAuthConfig = BaseAuthConfig & { + type: 'http'; +} & ( + | { + scheme: 'Bearer'; + /** For Bearer. Supports $ENV_VAR, !command, or literal. */ + token: string; + } + | { + scheme: 'Basic'; + /** For Basic. Supports $ENV_VAR, !command, or literal. */ + username: string; + /** For Basic. Supports $ENV_VAR, !command, or literal. */ + password: string; + } + | { + /** Any IANA-registered scheme (e.g., "Digest", "HOBA", "Custom"). */ + scheme: string; + /** Raw value to be sent as "Authorization: ". Supports $ENV_VAR, !command, or literal. */ + value: string; + } + ); + +/** Client config corresponding to OAuth2SecurityScheme. */ +export interface OAuth2AuthConfig extends BaseAuthConfig { + type: 'oauth2'; + client_id?: string; + client_secret?: string; + scopes?: string[]; + /** Override or provide the authorization endpoint URL. Discovered from agent card if omitted. */ + authorization_url?: string; + /** Override or provide the token endpoint URL. Discovered from agent card if omitted. */ + token_url?: string; + issuer?: string; + audiences?: string[]; + redirect_uri?: string; + token_param_name?: string; + registration_url?: string; +} + +/** Client config corresponding to OpenIdConnectSecurityScheme. */ +export interface OpenIdConnectAuthConfig extends BaseAuthConfig { + type: 'openIdConnect'; + issuer_url: string; + client_id: string; + client_secret?: string; + target_audience?: string; + scopes?: string[]; +} + +export type A2AAuthConfig = + | GoogleCredentialsAuthConfig + | ApiKeyAuthConfig + | HttpAuthConfig + | OAuth2AuthConfig + | OpenIdConnectAuthConfig; + +export interface AuthConfigDiff { + requiredSchemes: string[]; + configuredType?: A2AAuthProviderType; + missingConfig: string[]; +} + +export interface AuthValidationResult { + valid: boolean; + diff?: AuthConfigDiff; +} diff --git a/packages/core/src/agents/auth-provider/value-resolver.test.ts b/packages/core/src/agents/auth-provider/value-resolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..918eea9b4150a80f83b427a25af0f9d32ed6b9ae --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.test.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + resolveAuthValue, + needsResolution, + maskSensitiveValue, +} from './value-resolver.js'; +import * as shellUtils from '../../utils/shell-utils.js'; + +vi.mock('../../utils/shell-utils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + spawnAsync: vi.fn(), + }; +}); + +describe('value-resolver', () => { + describe('resolveAuthValue', () => { + describe('environment variables', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should resolve environment variable with $ prefix', async () => { + vi.stubEnv('TEST_API_KEY', 'secret-key-123'); + const result = await resolveAuthValue('$TEST_API_KEY'); + expect(result).toBe('secret-key-123'); + }); + + it('should throw error for unset environment variable', async () => { + await expect(resolveAuthValue('$UNSET_VAR_12345')).rejects.toThrow( + "Environment variable 'UNSET_VAR_12345' is not set or is empty", + ); + }); + + it('should throw error for empty environment variable', async () => { + vi.stubEnv('EMPTY_VAR', ''); + await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow( + "Environment variable 'EMPTY_VAR' is not set or is empty", + ); + }); + }); + + describe('shell commands', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should execute shell command with ! prefix', async () => { + vi.mocked(shellUtils.spawnAsync).mockResolvedValue({ + stdout: 'hello\n', + stderr: '', + }); + const result = await resolveAuthValue('!echo hello'); + expect(result).toBe('hello'); + }); + + it('should trim whitespace from command output', async () => { + vi.mocked(shellUtils.spawnAsync).mockResolvedValue({ + stdout: ' hello \n', + stderr: '', + }); + const result = await resolveAuthValue('!echo " hello "'); + expect(result).toBe('hello'); + }); + + it('should throw error for empty command', async () => { + await expect(resolveAuthValue('!')).rejects.toThrow( + 'Empty command in auth value', + ); + }); + + it('should throw error for command that returns empty output', async () => { + vi.mocked(shellUtils.spawnAsync).mockResolvedValue({ + stdout: '', + stderr: '', + }); + await expect(resolveAuthValue('!echo -n ""')).rejects.toThrow( + 'returned empty output', + ); + }); + + it('should throw error for failed command', async () => { + vi.mocked(shellUtils.spawnAsync).mockRejectedValue( + new Error('Command failed'), + ); + await expect( + resolveAuthValue('!nonexistent-command-12345'), + ).rejects.toThrow(/Command.*failed/); + }); + + it('should throw error for timeout', async () => { + const timeoutError = new Error('AbortError'); + timeoutError.name = 'AbortError'; + vi.mocked(shellUtils.spawnAsync).mockRejectedValue(timeoutError); + await expect(resolveAuthValue('!sleep 100')).rejects.toThrow( + /timed out after/, + ); + }); + }); + + describe('literal values', () => { + it('should return literal value as-is', async () => { + const result = await resolveAuthValue('literal-api-key'); + expect(result).toBe('literal-api-key'); + }); + + it('should return empty string as-is', async () => { + const result = await resolveAuthValue(''); + expect(result).toBe(''); + }); + + it('should not treat values starting with other characters as special', async () => { + const result = await resolveAuthValue('api-key-123'); + expect(result).toBe('api-key-123'); + }); + }); + + describe('escaped literals', () => { + it('should return $ literal when value starts with $$', async () => { + const result = await resolveAuthValue('$$LITERAL'); + expect(result).toBe('$LITERAL'); + }); + + it('should return ! literal when value starts with !!', async () => { + const result = await resolveAuthValue('!!not-a-command'); + expect(result).toBe('!not-a-command'); + }); + }); + }); + + describe('needsResolution', () => { + it('should return true for environment variable reference', () => { + expect(needsResolution('$ENV_VAR')).toBe(true); + }); + + it('should return true for command reference', () => { + expect(needsResolution('!command')).toBe(true); + }); + + it('should return false for literal value', () => { + expect(needsResolution('literal')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(needsResolution('')).toBe(false); + }); + }); + + describe('maskSensitiveValue', () => { + it('should mask value longer than 12 characters', () => { + expect(maskSensitiveValue('1234567890abcd')).toBe('12****cd'); + }); + + it('should return **** for short values', () => { + expect(maskSensitiveValue('short')).toBe('****'); + }); + + it('should return **** for exactly 12 characters', () => { + expect(maskSensitiveValue('123456789012')).toBe('****'); + }); + + it('should return **** for empty string', () => { + expect(maskSensitiveValue('')).toBe('****'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/value-resolver.ts b/packages/core/src/agents/auth-provider/value-resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..c349a574984a97c848474f1f995c85c67dd31da9 --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '../../utils/debugLogger.js'; +import { getShellConfiguration, spawnAsync } from '../../utils/shell-utils.js'; + +const COMMAND_TIMEOUT_MS = 60_000; + +/** + * Resolves a value that may be an environment variable reference, + * a shell command, or a literal value. + * + * Supported formats: + * - `$ENV_VAR`: Read from environment variable + * - `!command`: Execute shell command and use output (trimmed) + * - `$$` or `!!`: Escape prefix, returns rest as literal + * - Any other string: Use as literal value + * + * @param value The value to resolve + * @returns The resolved value + * @throws Error if environment variable is not set or command fails + */ +export async function resolveAuthValue(value: string): Promise { + // Support escaping with double prefix (e.g. $$ or !!). + // Strips one prefix char: $$FOO β†’ $FOO, !!cmd β†’ !cmd (literal, not resolved). + if (value.startsWith('$$') || value.startsWith('!!')) { + return value.slice(1); + } + + // Environment variable: $MY_VAR + if (value.startsWith('$')) { + const envVar = value.slice(1); + const resolved = process.env[envVar]; + if (resolved === undefined || resolved === '') { + throw new Error( + `Environment variable '${envVar}' is not set or is empty. ` + + `Please set it before using this agent.`, + ); + } + debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`); + return resolved; + } + + // Shell command: !command arg1 arg2 + if (value.startsWith('!')) { + const command = value.slice(1).trim(); + if (!command) { + throw new Error('Empty command in auth value. Expected format: !command'); + } + + debugLogger.debug(`[AuthValueResolver] Executing command for auth value`); + + const shellConfig = getShellConfiguration(); + try { + const { stdout } = await spawnAsync( + shellConfig.executable, + [...shellConfig.argsPrefix, command], + { + signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS), + windowsHide: true, + }, + ); + + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error(`Command '${command}' returned empty output`); + } + return trimmed; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `Command '${command}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds`, + ); + } + throw error; + } + } + + // Literal value - return as-is + return value; +} + +/** + * Check if a value needs resolution (is an env var or command reference). + */ +export function needsResolution(value: string): boolean { + return value.startsWith('$') || value.startsWith('!'); +} + +/** + * Mask a sensitive value for logging purposes. + * Shows the first and last 2 characters with asterisks in between. + */ +export function maskSensitiveValue(value: string): string { + if (value.length <= 12) { + return '****'; + } + return `${value.slice(0, 2)}****${value.slice(-2)}`; +} diff --git a/packages/core/src/agents/browser/analyzeScreenshot.test.ts b/packages/core/src/agents/browser/analyzeScreenshot.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfe1f42e083c977fa4389354d85fb6a68c666733 --- /dev/null +++ b/packages/core/src/agents/browser/analyzeScreenshot.test.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js'; +import type { BrowserManager, McpToolCallResult } from './browserManager.js'; +import type { Config } from '../../config/config.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import { Environment } from '@google/genai'; + +const mockMessageBus = { + waitForConfirmation: vi.fn().mockResolvedValue({ approved: true }), +} as unknown as MessageBus; + +function createMockBrowserManager( + callToolResult?: McpToolCallResult, +): BrowserManager { + return { + callTool: vi.fn().mockResolvedValue( + callToolResult ?? { + content: [ + { type: 'text', text: 'Screenshot captured' }, + { + type: 'image', + data: 'base64encodeddata', + mimeType: 'image/png', + }, + ], + }, + ), + } as unknown as BrowserManager; +} + +function createMockConfig( + generateContentResult?: unknown, + generateContentError?: Error, + modelName: string = 'gemini-2.5-computer-use-preview-10-2025', +): Config { + const generateContent = generateContentError + ? vi.fn().mockRejectedValue(generateContentError) + : vi.fn().mockResolvedValue( + generateContentResult ?? { + candidates: [ + { + content: { + parts: [ + { + text: 'The blue submit button is at coordinates (250, 400).', + }, + ], + }, + }, + ], + }, + ); + + return { + getBrowserAgentConfig: vi.fn().mockReturnValue({ + customConfig: { visualModel: modelName }, + }), + getContentGenerator: vi.fn().mockReturnValue({ + generateContent, + }), + } as unknown as Config; +} + +describe('analyzeScreenshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createAnalyzeScreenshotTool', () => { + it('creates a tool with the correct name and schema', () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig(); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + expect(tool.name).toBe('analyze_screenshot'); + }); + }); + + describe('AnalyzeScreenshotInvocation', () => { + it('captures a screenshot and returns visual analysis', async () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig(); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Find the blue submit button', + }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + // Verify screenshot was captured + expect(browserManager.callTool).toHaveBeenCalledWith( + 'take_screenshot', + {}, + ); + + // Verify the visual model was called + const contentGenerator = config.getContentGenerator(); + expect(contentGenerator.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gemini-2.5-computer-use-preview-10-2025', + config: expect.objectContaining({ + tools: [ + { + computerUse: { + environment: Environment.ENVIRONMENT_BROWSER, + excludedPredefinedFunctions: [ + 'open_web_browser', + 'click_at', + 'key_combination', + 'drag_and_drop', + ], + }, + }, + ], + }), + contents: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + parts: expect.arrayContaining([ + expect.objectContaining({ + inlineData: { + mimeType: 'image/png', + data: 'base64encodeddata', + }, + }), + ]), + }), + ]), + }), + 'visual-analysis', + 'utility_tool', + ); + + // Verify result + expect(result.llmContent).toContain('Visual Analysis Result'); + expect(result.llmContent).toContain( + 'The blue submit button is at coordinates (250, 400).', + ); + expect(result.error).toBeUndefined(); + }); + + it('omits computerUse tools for non-computer-use models', async () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig(undefined, undefined, 'gemini-2.0-flash'); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Find the search bar', + }); + await invocation.execute({ abortSignal: new AbortController().signal }); + + const contentGenerator = config.getContentGenerator(); + expect(contentGenerator.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gemini-2.0-flash', + config: expect.not.objectContaining({ + tools: expect.anything(), + }), + }), + 'visual-analysis', + 'utility_tool', + ); + }); + + it('returns an error when screenshot capture fails (no image)', async () => { + const browserManager = createMockBrowserManager({ + content: [{ type: 'text', text: 'No screenshot available' }], + }); + const config = createMockConfig(); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Find the button', + }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('Failed to capture screenshot'); + // Should NOT call the visual model + const contentGenerator = config.getContentGenerator(); + expect(contentGenerator.generateContent).not.toHaveBeenCalled(); + }); + + it('returns an error when visual model returns empty response', async () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig({ + candidates: [{ content: { parts: [] } }], + }); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Check the layout', + }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('Visual model returned no analysis'); + }); + + it('returns a model-unavailability fallback for 404 errors', async () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig( + undefined, + new Error('Model not found: 404'), + ); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Find the red error', + }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain( + 'Visual analysis model is not available', + ); + }); + + it('returns a model-unavailability fallback for 403 errors', async () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig( + undefined, + new Error('permission denied: 403'), + ); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Identify the element', + }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain( + 'Visual analysis model is not available', + ); + }); + + it('returns a generic error for non-model errors', async () => { + const browserManager = createMockBrowserManager(); + const config = createMockConfig(undefined, new Error('Network timeout')); + const tool = createAnalyzeScreenshotTool( + browserManager, + config, + mockMessageBus, + ); + + const invocation = tool.build({ + instruction: 'Find something', + }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('Visual analysis failed'); + expect(result.llmContent).toContain('Network timeout'); + }); + }); +}); diff --git a/packages/core/src/agents/browser/analyzeScreenshot.ts b/packages/core/src/agents/browser/analyzeScreenshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d702b062166ff429e087d4cc422674260bcded7 --- /dev/null +++ b/packages/core/src/agents/browser/analyzeScreenshot.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Tool for visual identification via a single model call. + * + * The semantic browser agent uses this tool when it needs to identify + * elements by visual attributes not present in the accessibility tree + * (e.g., color, layout, precise coordinates). + * + * Unlike the semantic agent which works with the accessibility tree, + * this tool sends a screenshot to a computer-use model for visual analysis. + * It returns the model's analysis (coordinates, element descriptions) back + * to the browser agent, which retains full control of subsequent actions. + */ + +import { + DeclarativeTool, + BaseToolInvocation, + Kind, + type ToolResult, + type ToolInvocation, + type ExecuteOptions, +} from '../../tools/tools.js'; +import { Environment } from '@google/genai'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import type { BrowserManager } from './browserManager.js'; +import type { Config } from '../../config/config.js'; +import { + getVisualAgentModel, + isComputerUseModel, +} from './modelAvailability.js'; +import { debugLogger } from '../../utils/debugLogger.js'; +import { LlmRole } from '../../telemetry/llmRole.js'; + +/** + * System prompt for the visual analysis model call. + */ +const VISUAL_SYSTEM_PROMPT = `You are a Visual Analysis Agent. You receive a screenshot of a browser page and an instruction. + +Your job is to ANALYZE the screenshot and provide precise information that a browser automation agent can act on. + +COORDINATE SYSTEM: +- Coordinates are pixel-based relative to the viewport +- (0,0) is top-left of the visible area +- Estimate element positions from the screenshot + +RESPONSE FORMAT: +- For coordinate identification: provide exact (x, y) pixel coordinates +- For element identification: describe the element's visual location and appearance +- For layout analysis: describe the spatial relationships between elements +- Be concise and actionable β€” the browser agent will use your response to decide what action to take + +IMPORTANT: +- You are NOT performing actions β€” you are only providing visual analysis +- Include coordinates when possible so the caller can use click_at(x, y) +- If the element is not visible in the screenshot, say so explicitly`; + +/** + * Invocation for the analyze_screenshot tool. + * Makes a single generateContent call with a screenshot. + */ +class AnalyzeScreenshotInvocation extends BaseToolInvocation< + Record, + ToolResult +> { + constructor( + private readonly browserManager: BrowserManager, + private readonly config: Config, + params: Record, + messageBus: MessageBus, + ) { + super(params, messageBus, 'analyze_screenshot', 'Analyze Screenshot'); + } + + getDescription(): string { + const instruction = String(this.params['instruction'] ?? ''); + return `Visual analysis: "${instruction}"`; + } + + async execute({ abortSignal: signal }: ExecuteOptions): Promise { + try { + const instruction = String(this.params['instruction'] ?? ''); + + debugLogger.log(`Visual analysis requested: ${instruction}`); + + // Capture screenshot via MCP tool + const screenshotResult = await this.browserManager.callTool( + 'take_screenshot', + {}, + ); + + // Extract base64 image data from MCP response. + // Search ALL content items for image type β€” MCP returns [text, image] + // where content[0] is a text description and content[1] is the actual PNG. + let screenshotBase64 = ''; + let mimeType = 'image/png'; + if (screenshotResult.content && Array.isArray(screenshotResult.content)) { + for (const item of screenshotResult.content) { + if (item.type === 'image' && item.data) { + screenshotBase64 = item.data; + mimeType = item.mimeType ?? 'image/png'; + break; + } + } + } + + if (!screenshotBase64) { + return { + llmContent: + 'Failed to capture screenshot for visual analysis. Use accessibility tree elements instead.', + returnDisplay: 'Screenshot capture failed', + error: { message: 'Screenshot capture failed' }, + }; + } + + // Make a single generateContent call with the visual model + const visualModel = getVisualAgentModel(this.config); + const contentGenerator = this.config.getContentGenerator(); + + // Computer-use models require the computerUse tool declaration in every + // request. We exclude all predefined action functions so the model + // provides text analysis rather than issuing actions. + // Non-computer-use models (e.g., gemini-2.0-flash) do plain text + // analysis natively and don't need this declaration. + const tools = isComputerUseModel(visualModel) + ? [ + { + computerUse: { + environment: Environment.ENVIRONMENT_BROWSER, + excludedPredefinedFunctions: [ + 'open_web_browser', + 'click_at', + 'key_combination', + 'drag_and_drop', + ], + }, + }, + ] + : undefined; + + const response = await contentGenerator.generateContent( + { + model: visualModel, + config: { + temperature: 0, + topP: 0.95, + systemInstruction: VISUAL_SYSTEM_PROMPT, + abortSignal: signal, + ...(tools ? { tools } : {}), + }, + contents: [ + { + role: 'user', + parts: [ + { + text: `Analyze this screenshot and respond to the following instruction:\n\n${instruction}`, + }, + { + inlineData: { + mimeType, + data: screenshotBase64, + }, + }, + ], + }, + ], + }, + 'visual-analysis', + LlmRole.UTILITY_TOOL, + ); + + // Extract response content. Computer-use models may still return + // functionCall parts even with exclusions, so we handle both text + // and functionCall parts defensively. + const parts = response.candidates?.[0]?.content?.parts ?? []; + + const textParts = parts.filter((p) => p.text).map((p) => p.text!); + + const functionCallParts = parts + .filter((p) => p.functionCall) + .map((p) => { + const fc = p.functionCall!; + const argsStr = fc.args ? JSON.stringify(fc.args) : ''; + return `Action: ${fc.name}${argsStr ? ` with args ${argsStr}` : ''}`; + }); + + const responseText = [...textParts, ...functionCallParts].join('\n'); + + if (!responseText) { + return { + llmContent: + 'Visual model returned no analysis. Use accessibility tree elements instead.', + returnDisplay: 'Visual analysis returned empty response', + error: { message: 'Empty visual analysis response' }, + }; + } + + debugLogger.log(`Visual analysis complete: ${responseText}`); + + return { + llmContent: `Visual Analysis Result:\n${responseText}`, + returnDisplay: `Visual Analysis Result:\n${responseText}`, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + debugLogger.error(`Visual analysis failed: ${errorMsg}`); + + // Provide a graceful fallback message for model unavailability + const isModelError = + errorMsg.includes('404') || + errorMsg.includes('403') || + errorMsg.includes('not found') || + errorMsg.includes('permission'); + + const fallbackMsg = isModelError + ? 'Visual analysis model is not available. Use accessibility tree elements (uids from take_snapshot) for all interactions instead.' + : `Visual analysis failed: ${errorMsg}. Use accessibility tree elements instead.`; + + return { + llmContent: fallbackMsg, + returnDisplay: fallbackMsg, + error: { message: errorMsg }, + }; + } + } +} + +/** + * DeclarativeTool for screenshot-based visual analysis. + */ +class AnalyzeScreenshotTool extends DeclarativeTool< + Record, + ToolResult +> { + constructor( + private readonly browserManager: BrowserManager, + private readonly config: Config, + messageBus: MessageBus, + ) { + super( + 'analyze_screenshot', + 'analyze_screenshot', + 'Analyze the current page visually using a screenshot. Use when you need to identify elements by visual attributes (color, layout, position) not available in the accessibility tree, or when you need precise pixel coordinates for click_at. Returns visual analysis β€” you perform the actions yourself.', + Kind.Other, + { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What to identify or analyze visually (e.g., "Find the coordinates of the blue submit button", "What is the layout of the navigation menu?").', + }, + }, + required: ['instruction'], + }, + messageBus, + true, // isOutputMarkdown + false, // canUpdateOutput + ); + } + + build( + params: Record, + ): ToolInvocation, ToolResult> { + return new AnalyzeScreenshotInvocation( + this.browserManager, + this.config, + params, + this.messageBus, + ); + } +} + +/** + * Creates the analyze_screenshot tool for the browser agent. + */ +export function createAnalyzeScreenshotTool( + browserManager: BrowserManager, + config: Config, + messageBus: MessageBus, +): AnalyzeScreenshotTool { + return new AnalyzeScreenshotTool(browserManager, config, messageBus); +} diff --git a/packages/core/src/agents/browser/automationOverlay.ts b/packages/core/src/agents/browser/automationOverlay.ts new file mode 100644 index 0000000000000000000000000000000000000000..11d821c95cb8717752cb75deef56033bd3c69349 --- /dev/null +++ b/packages/core/src/agents/browser/automationOverlay.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Automation overlay utilities for visual indication during browser automation. + * + * Provides functions to inject and remove a pulsating blue border overlay + * that indicates when the browser is under AI agent control. + * + * Uses the Web Animations API instead of injected