Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi 4fda0e75dd feat(core): enforce 512 tool limit and add warning for ignored tools
- Limits active tools to 512 in ToolRegistry to prevent Gemini API errors.
- Prioritizes built-in tools and command-discovered tools over MCP tools.
- Adds a warning message on startup and context refresh when tools are ignored.
2026-05-11 11:34:22 -04:00
150 changed files with 1101 additions and 3393 deletions
+1 -4
View File
@@ -5,10 +5,7 @@
"autoMemory": true,
"memoryManager": true,
"topicUpdateNarration": true,
"voiceMode": true,
"adk": {
"agentSessionNoninteractiveEnabled": true
}
"voiceMode": true
},
"general": {
"devtools": true
+6 -6
View File
@@ -174,9 +174,9 @@ runs:
npm publish \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
--tag staging-tmp
--no-tag
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
fi
- name: '🔗 Install latest core package'
@@ -222,9 +222,9 @@ runs:
npm publish \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
--tag staging-tmp
--no-tag
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false
fi
- name: 'Get a2a-server Token'
@@ -249,9 +249,9 @@ runs:
npm publish \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--tag staging-tmp
--no-tag
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
fi
- name: '🔬 Verify NPM release by version'
+4 -41
View File
@@ -85,51 +85,14 @@ module.exports = async ({ github, context, core }) => {
continue;
}
let labelsToAdd = entry.labels_to_add || [];
const labelsToAdd = entry.labels_to_add || [];
labelsToAdd.push('status/bot-triaged');
let labelsToRemove = entry.labels_to_remove || [];
labelsToRemove.push('status/need-triage');
if (labelsToAdd.includes('status/manual-triage')) {
// If the AI flagged it for manual triage, remove bot-triaged if it exists
labelsToRemove.push('status/bot-triaged');
// Ensure we don't accidentally try to add bot-triaged if the AI returned it
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
} else {
// Standard successful bot triage
labelsToAdd.push('status/bot-triaged');
}
// Deduplicate arrays
labelsToAdd = [...new Set(labelsToAdd)];
// Deduplicate array
labelsToRemove = [...new Set(labelsToRemove)];
// Enforce mutually exclusive area labels
const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/'));
if (areaLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`,
);
const firstArea = areaLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('area/') || l === firstArea,
);
}
// Enforce mutually exclusive priority labels
const priorityLabelsToAdd = labelsToAdd.filter((l) =>
l.startsWith('priority/'),
);
if (priorityLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`,
);
const firstPriority = priorityLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('priority/') || l === firstPriority,
);
}
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
module.exports = async ({ github, context, core }) => {
core.info('Fetching open issues to check for conflicting labels...');
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const conflictingLabelIssues = [];
for (const issue of issues) {
if (issue.pull_request) continue;
const areaLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('area/'))
.map((l) => l.name);
const priorityLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('priority/'))
.map((l) => l.name);
if (areaLabels.length > 1 || priorityLabels.length > 1) {
let message = `Issue #${issue.number} has conflicting labels:`;
if (areaLabels.length > 1)
message += ` multiple areas (${areaLabels.join(', ')}).`;
if (priorityLabels.length > 1)
message += ` multiple priorities (${priorityLabels.join(', ')}).`;
core.info(message);
conflictingLabelIssues.push({
number: issue.number,
title: issue.title,
body: issue.body || '',
});
}
}
// Limit to 50 to avoid overwhelming the AI in a single run
const issuesToProcess = conflictingLabelIssues.slice(0, 50);
fs.writeFileSync(
'conflicting_labels_issues.json',
JSON.stringify(issuesToProcess, null, 2),
);
core.info(
`Found ${conflictingLabelIssues.length} issues with conflicting labels. Wrote ${issuesToProcess.length} to conflicting_labels_issues.json`,
);
};
@@ -30,7 +30,6 @@ jobs:
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
ref: '${{ inputs.ref || github.ref }}'
persist-credentials: false
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
-4
View File
@@ -148,7 +148,6 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
persist-credentials: false
- name: 'Set up Node.js ${{ matrix.node-version }}'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -194,7 +193,6 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
persist-credentials: false
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -235,7 +233,6 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
persist-credentials: false
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -317,7 +314,6 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
persist-credentials: false
fetch-depth: 0
- name: 'Set up Node.js 20.x'
-10
View File
@@ -57,7 +57,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
fetch-depth: 0
@@ -131,8 +130,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Link Checker'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
@@ -160,8 +157,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Set up Node.js ${{ matrix.node-version }}'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -257,8 +252,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Set up Node.js ${{ matrix.node-version }}'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -346,7 +339,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
- name: 'Initialize CodeQL'
@@ -371,7 +363,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
fetch-depth: 1
@@ -399,7 +390,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
- name: 'Set up Node.js 20.x'
-3
View File
@@ -43,7 +43,6 @@ jobs:
with:
ref: '${{ github.event.pull_request.head.sha }}'
repository: '${{ github.repository }}'
persist-credentials: false
- name: 'Set up Node.js ${{ matrix.node-version }}'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -87,7 +86,6 @@ jobs:
with:
ref: '${{ github.event.pull_request.head.sha }}'
repository: '${{ github.repository }}'
persist-credentials: false
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -127,7 +125,6 @@ jobs:
with:
ref: '${{ github.event.pull_request.head.sha }}'
repository: '${{ github.repository }}'
persist-credentials: false
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
-1
View File
@@ -19,7 +19,6 @@ jobs:
with:
fetch-depth: 0
ref: 'main'
persist-credentials: false
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
-2
View File
@@ -24,8 +24,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Setup Pages'
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
-2
View File
@@ -38,7 +38,6 @@ jobs:
with:
# Check out the trusted code from main for detection
fetch-depth: 0
persist-credentials: false
- name: 'Detect Steering Changes'
id: 'detect'
@@ -103,7 +102,6 @@ jobs:
# This only runs AFTER manual approval
ref: '${{ github.event.pull_request.head.sha }}'
fetch-depth: 0
persist-credentials: false
- name: 'Remove Approval Notification'
# Run even if other steps fail, to ensure we clean up the "Action Required" message
-4
View File
@@ -46,8 +46,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -107,8 +105,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Download Logs'
uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4
@@ -48,8 +48,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Log in to GitHub Container Registry'
uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3
@@ -90,8 +90,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Generate GitHub App Token'
id: 'generate_token'
+77 -158
View File
@@ -2,8 +2,7 @@ name: '🧠 Gemini CLI Bot: Brain'
on:
schedule:
- cron: '0 0 * * *' # Nightly (Strategic Metrics)
- cron: '0 */4 * * *' # Every 4 hours (Issue Fixing)
- cron: '0 0 * * *' # Every 24 hours
issue_comment:
types: ['created']
workflow_dispatch:
@@ -27,89 +26,69 @@ on:
enable_prs:
description: 'Enable PRs (automatically promote changes to PRs)'
type: 'boolean'
default: true
mandate:
description: 'Mandate to execute'
type: 'choice'
options:
- 'auto'
- 'issue-fixer'
- 'metrics'
- 'interactive'
default: 'auto'
default: false
concurrency:
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || github.ref }}'
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number || github.ref }}'
cancel-in-progress: true
jobs:
reasoning:
name: 'Brain (Reasoning Layer)'
runs-on: 'ubuntu-latest'
timeout-minutes: 60
if: |
github.repository == 'google-gemini/gemini-cli' && (
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive != 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive == 'true') ||
(github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association)) ||
(github.event_name == 'pull_request_review_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
)
# The reasoning phase is strictly readonly.
permissions:
contents: 'read'
issues: 'read'
actions: 'read'
pull-requests: 'read'
outputs:
sha: '${{ steps.get_sha.outputs.sha }}'
target_sha: '${{ steps.get_target_sha.outputs.sha }}'
patch_base_sha: '${{ steps.generate_patch.outputs.patch_base_sha }}'
actions: 'read'
env:
GEMINI_CLI_TRUST_WORKSPACE: 'true'
steps:
- name: 'Checkout Branch (Agent Code)'
- name: 'Determine Checkout Ref'
id: 'determine_ref'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
run: |
REF="${{ github.ref }}"
if [ -n "$ISSUE_NUMBER" ]; then
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
if [ -n "$PR_HEAD" ]; then
REF="$PR_HEAD"
fi
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.ref }}'
path: 'agent-code'
ref: '${{ steps.determine_ref.outputs.ref }}'
fetch-depth: 0
persist-credentials: false
- name: 'Get Current SHA'
id: 'get_sha'
working-directory: 'agent-code'
run: 'echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'agent-code/package-lock.json'
- name: 'Install dependencies'
working-directory: 'agent-code'
run: 'npm ci'
- name: 'Build Gemini CLI'
working-directory: 'agent-code'
run: 'npm run bundle'
- name: 'Checkout Main (Target Repo)'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: 'main'
path: 'repo-target'
fetch-depth: 0
persist-credentials: false
- name: 'Get Target SHA'
id: 'get_target_sha'
working-directory: 'repo-target'
run: 'echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'
- name: 'Download Previous State'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
if [ "${{ github.event.inputs.clear_memory }}" = "true" ]; then
@@ -118,19 +97,19 @@ jobs:
fi
# Find the last successful run of this workflow
LAST_RUN_ID=$(gh run list -R "${{ github.repository }}" --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
LAST_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
if [ -n "$LAST_RUN_ID" ]; then
echo "Found previous successful run: $LAST_RUN_ID"
# Download brain memory to a temp dir so we can selectively restore only persistent state
mkdir -p .temp_brain_data
gh run download "$LAST_RUN_ID" -R "${{ github.repository }}" -n brain-data -D .temp_brain_data || echo "brain-data not found"
gh run download "$LAST_RUN_ID" -n brain-data -D .temp_brain_data || echo "brain-data not found"
# Restore only persistent memory files
cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md repo-target/tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
mkdir -p repo-target/tools/gemini-cli-bot/history/
cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv repo-target/tools/gemini-cli-bot/history/ 2>/dev/null || true
cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
mkdir -p tools/gemini-cli-bot/history/
cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv tools/gemini-cli-bot/history/ 2>/dev/null || true
rm -rf .temp_brain_data
else
echo "No previous successful run found."
@@ -138,62 +117,24 @@ jobs:
- name: 'Collect Current Metrics'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
working-directory: 'agent-code'
run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
- name: 'Run Brain Phases'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GEMINI_MODEL: 'gemini-3-flash-preview'
GEMINI_CLI_HOME: '../agent-code/tools/gemini-cli-bot'
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'true' }}"
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}"
TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}'
TRIGGER_COMMENT_ID: '${{ github.event.comment.id || github.event.inputs.comment_id }}'
# Enable detailed activity logging for debugging
GEMINI_TELEMETRY_ENABLED: 'true'
GEMINI_TELEMETRY_LOG_PROMPTS: 'true'
GEMINI_TELEMETRY_OUTFILE: 'brain-telemetry.json'
GEMINI_DEBUG_LOG_FILE: 'brain-debug.log'
GH_PAGER: ''
working-directory: 'repo-target'
run: |
# Determine intent and prompt
MANDATE_INPUT="${{ github.event.inputs.mandate || 'auto' }}"
# Initialize defaults
PROMPT_FILE="../agent-code/tools/gemini-cli-bot/brain/scheduled.md"
MANDATE="Your specific mandate for this run: Implement surgical fixes for repository issues (issue-fixer skill)."
# Resolve Mandate and Prompt File
if [ "$MANDATE_INPUT" = "issue-fixer" ]; then
echo "Trigger: Manual Override (issue-fixer)"
MANDATE="Your specific mandate for this run: Implement surgical fixes for repository issues (issue-fixer skill)."
elif [ "$MANDATE_INPUT" = "metrics" ]; then
echo "Trigger: Manual Override (metrics)"
MANDATE="Your specific mandate for this run: Analyze repository metrics to identify bottlenecks and self-evolve (metrics skill)."
elif [ "$MANDATE_INPUT" = "interactive" ]; then
echo "Trigger: Manual Override (interactive)"
PROMPT_FILE="../agent-code/tools/gemini-cli-bot/brain/interactive.md"
MANDATE="Your specific mandate for this run: Respond to the user request in <untrusted_context>."
elif [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then
echo "Trigger: Issue/PR Comment or Interactive Dispatch"
PROMPT_FILE="../agent-code/tools/gemini-cli-bot/brain/interactive.md"
MANDATE="Your specific mandate for this run: Respond to the user request in <untrusted_context>."
elif [ "${{ github.event.schedule }}" = "0 0 * * *" ]; then
echo "Trigger: Nightly Schedule (Metrics)"
MANDATE="Your specific mandate for this run: Analyze repository metrics to identify bottlenecks and self-evolve (metrics skill)."
else
echo "Trigger: Scheduled or Manual Dispatch (Default: Issue-Fixer)"
PROMPT_PATH="tools/gemini-cli-bot/brain/metrics.md"
if [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then
PROMPT_PATH="tools/gemini-cli-bot/brain/interactive.md"
export ENABLE_PRS="true"
fi
echo "Selected Prompt: $PROMPT_FILE"
echo "Selected Mandate: $MANDATE"
# Prepare Context if available
touch trigger_context.md
if [ -n "$TRIGGER_ISSUE_NUMBER" ]; then
echo "<untrusted_context>" > trigger_context.md
@@ -211,18 +152,9 @@ jobs:
echo "</untrusted_context>" >> trigger_context.md
fi
# Pass PR Enablement Directive
PR_DIRECTIVE="PR creation is DISABLED. You MUST NOT stage files."
if [ "${{ github.event.inputs.enable_prs || 'true' }}" = "true" ] || [ "${{ github.event_name }}" = "issue_comment" ]; then
PR_DIRECTIVE="PR creation is ENABLED. You MUST activate the 'prs' skill to stage changes if proposing fixes."
fi
cat trigger_context.md "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md > combined_prompt.md
# Assemble final prompt: Context + Base Brain + Specific Mandate
echo "System: $PR_DIRECTIVE" > combined_prompt.md
cat trigger_context.md "$PROMPT_FILE" >> combined_prompt.md
echo -e "\n\n# MANDATE FOR THIS RUN\n$MANDATE" >> combined_prompt.md
node ../agent-code/bundle/gemini.js --policy ../agent-code/tools/gemini-cli-bot/ci-policy.toml --prompt="$(cat combined_prompt.md)"
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
echo "Agent failed to respond. Generating fallback error message."
@@ -232,21 +164,17 @@ jobs:
fi
- name: 'Run Critique Phase'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
working-directory: 'repo-target'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GEMINI_MODEL: 'gemini-3-flash-preview'
GEMINI_CLI_HOME: '../agent-code/tools/gemini-cli-bot'
GH_PAGER: ''
run: |
if git diff --staged --quiet; then
echo "No changes staged. Skipping critique."
echo "[APPROVED]" > critique_result.txt
else
node ../agent-code/bundle/gemini.js --policy ../agent-code/tools/gemini-cli-bot/ci-policy.toml --prompt="$(cat ../agent-code/tools/gemini-cli-bot/.gemini/skills/critique/SKILL.md)" 2>&1 | tee critique_output.log
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
echo "[APPROVED]" > critique_result.txt
@@ -257,45 +185,29 @@ jobs:
fi
- name: 'Generate Patch'
id: 'generate_patch'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
working-directory: 'repo-target'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
run: |
touch bot-changes.patch
touch pr-description.md
touch pr-labels.txt
touch branch-name.txt
touch issue-comment.md
touch pr-comment.md
echo "patch_base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
if [ -f critique_result.txt ] && grep -q "\[APPROVED\]" critique_result.txt && ! grep -q "\[REJECTED\]" critique_result.txt; then
git diff --staged > bot-changes.patch
else
echo "Critique did not approve. Skipping patch generation."
fi
- name: 'Stage Artifacts'
if: 'always()'
run: |
mkdir -p staged-artifacts/tools/gemini-cli-bot/history
cp repo-target/tools/gemini-cli-bot/lessons-learned.md staged-artifacts/tools/gemini-cli-bot/ 2>/dev/null || true
cp repo-target/tools/gemini-cli-bot/history/*.csv staged-artifacts/tools/gemini-cli-bot/history/ 2>/dev/null || true
cp repo-target/brain-telemetry.json staged-artifacts/ 2>/dev/null || true
cp repo-target/brain-debug.log staged-artifacts/ 2>/dev/null || true
cp repo-target/bot-changes.patch staged-artifacts/ 2>/dev/null || true
cp repo-target/pr-description.md staged-artifacts/ 2>/dev/null || true
cp repo-target/branch-name.txt staged-artifacts/ 2>/dev/null || true
cp repo-target/pr-comment.md staged-artifacts/ 2>/dev/null || true
cp repo-target/pr-number.txt staged-artifacts/ 2>/dev/null || true
cp repo-target/issue-comment.md staged-artifacts/ 2>/dev/null || true
cp repo-target/pr-labels.txt staged-artifacts/ 2>/dev/null || true
- name: 'Archive Brain Data'
if: 'always()'
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'brain-data'
path: 'staged-artifacts/'
path: |
tools/gemini-cli-bot/lessons-learned.md
tools/gemini-cli-bot/history/*.csv
bot-changes.patch
pr-description.md
branch-name.txt
pr-comment.md
pr-number.txt
issue-comment.md
retention-days: 90
publish:
@@ -311,7 +223,7 @@ jobs:
steps:
- name: 'Generate GitHub App Token 🔑'
id: 'generate_token'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
@@ -322,10 +234,25 @@ jobs:
permission-pull-requests: 'write'
permission-issues: 'write'
- name: 'Determine Checkout Ref'
id: 'determine_ref'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
run: |
REF="main"
if [ -n "$ISSUE_NUMBER" ]; then
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
if [ -n "$PR_HEAD" ]; then
REF="$PR_HEAD"
fi
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ needs.reasoning.outputs.patch_base_sha || needs.reasoning.outputs.target_sha }}'
ref: '${{ steps.determine_ref.outputs.ref }}'
fetch-depth: 0
persist-credentials: false
@@ -336,7 +263,7 @@ jobs:
path: '${{ runner.temp }}/brain-data/'
- name: 'Create or Update PR'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
env:
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
FALLBACK_PAT: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
@@ -348,27 +275,27 @@ jobs:
BRANCH_NAME="bot/productivity-updates-$(date +'%Y%m%d%H%M%S')-${{ github.run_id }}"
if [ -f "${{ runner.temp }}/brain-data/branch-name.txt" ]; then
FILE_BRANCH=$(cat "${{ runner.temp }}/brain-data/branch-name.txt" | tr -d '[:space:]')
if [ -n "$FILE_BRANCH" ] && [ "$FILE_BRANCH" != "bot/" ]; then
BRANCH_NAME="$FILE_BRANCH"
fi
BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt")
fi
if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
echo "Warning: Branch name '$BRANCH_NAME' does not start with 'bot/'. Prepending 'bot/' for safety."
BRANCH_NAME="bot/$BRANCH_NAME"
echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
exit 1
fi
git checkout -B "$BRANCH_NAME"
git apply --3way --ignore-whitespace "${{ runner.temp }}/brain-data/bot-changes.patch"
git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
git add .
PR_TITLE="🤖 Gemini Bot Maintenance Update"
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
git commit -F "${{ runner.temp }}/brain-data/pr-description.md"
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
else
git commit -m "$PR_TITLE"
git commit -m "🤖 Gemini Bot Productivity Optimizations"
fi
PR_TITLE="🤖 Gemini Bot Productivity Optimizations"
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
fi
if ! git push origin "$BRANCH_NAME" --force; then
@@ -391,14 +318,6 @@ jobs:
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$NEW_BRANCH_NAME" --base main
fi
fi
if [ -s "${{ runner.temp }}/brain-data/pr-labels.txt" ]; then
while IFS= read -r label; do
if [ -n "$label" ]; then
gh pr edit "$BRANCH_NAME" --add-label "$label" || true
fi
done < "${{ runner.temp }}/brain-data/pr-labels.txt"
fi
fi
- name: 'Post PR/Issue Comment'
@@ -23,7 +23,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
fetch-depth: 0
- name: 'Setup Node.js'
@@ -33,8 +33,6 @@ jobs:
- name: 'Checkout repository'
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
with:
persist-credentials: false
- name: 'Lifecycle Management'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
@@ -28,8 +28,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Log in to GitHub Container Registry'
uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3
@@ -30,8 +30,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Generate GitHub App Token'
id: 'generate_token'
@@ -63,16 +61,6 @@ jobs:
const syncIssueTypes = require('./.github/scripts/sync-issue-types.cjs');
await syncIssueTypes({ github, context, core });
- name: 'Find Issues with Conflicting Labels'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const findConflictingLabels = require('./.github/scripts/find-conflicting-labels.cjs');
await findConflictingLabels({ github, context, core });
- name: 'Find untriaged issues'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
@@ -93,31 +81,22 @@ jobs:
echo '🏷️ Finding issues missing priority labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
echo '📏 Finding issues missing effort labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 5 --json number,title,body > no_effort_issues.json
--search 'is:open is:issue -label:status/bot-triaged -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 20 --json number,title,body > no_effort_issues.json
echo '🔄 Merging and deduplicating standard triage issues...'
if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi
jq -c -s 'add | unique_by(.number)' no_area_issues.json no_kind_issues.json no_priority_issues.json conflicting_labels_issues.json > standard_issues_to_triage.json
echo '🔄 Merging and deduplicating issues...'
jq -c -s 'add | unique_by(.number)' no_area_issues.json no_kind_issues.json no_priority_issues.json no_effort_issues.json no_type_issues.json > issues_to_triage.json
echo '📏 Deduplicating effort issues...'
jq -c -s 'add | unique_by(.number)' no_effort_issues.json > effort_issues_to_triage.json
STANDARD_COUNT="$(jq 'length' standard_issues_to_triage.json)"
EFFORT_COUNT="$(jq 'length' effort_issues_to_triage.json)"
if [ "$STANDARD_COUNT" -gt 0 ] || [ "$EFFORT_COUNT" -gt 0 ]; then
ISSUE_COUNT="$(jq 'length' issues_to_triage.json)"
if [ "$ISSUE_COUNT" -gt 0 ]; then
echo "has_issues=true" >> "${GITHUB_OUTPUT}"
echo "has_standard_issues=$([ "$STANDARD_COUNT" -gt 0 ] && echo 'true' || echo 'false')" >> "${GITHUB_OUTPUT}"
echo "has_effort_issues=$([ "$EFFORT_COUNT" -gt 0 ] && echo 'true' || echo 'false')" >> "${GITHUB_OUTPUT}"
else
echo "has_issues=false" >> "${GITHUB_OUTPUT}"
echo "has_standard_issues=false" >> "${GITHUB_OUTPUT}"
echo "has_effort_issues=false" >> "${GITHUB_OUTPUT}"
fi
echo "✅ Found ${STANDARD_COUNT} standard issues and ${EFFORT_COUNT} effort issues to triage! 🎯"
echo "✅ Found ${ISSUE_COUNT} unique issues to triage! 🎯"
- name: 'Create Gemini CLI Experiments Override'
if: |-
@@ -150,128 +129,11 @@ jobs:
core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`);
return labelNames;
- name: 'Run Standard Triage Analysis'
- name: 'Run Gemini Issue Analysis'
if: |-
steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_standard_issues == 'true'
steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_issues == 'true'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
id: 'gemini_standard_issue_analysis'
env:
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
REPOSITORY: '${{ github.repository }}'
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}'
gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}'
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}'
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
settings: |-
{
"maxSessionTurns": 25,
"coreTools": [
"run_shell_command(echo)",
"read_file"
],
"telemetry": {
"enabled": true,
"target": "gcp"
}
}
prompt: |-
## Role
You are an issue triage assistant. Analyze issues and identify
appropriate labels. Use the available tools to gather information;
do not ask for information to be provided.
## Steps
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage.
3. Review the issue title, body and any comments provided in the JSON file.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*.
5. Label Policy:
- If the issue already has a kind/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it.
- If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
- If the issue has exactly ONE area/ label, do not change it.
- If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
- If any of these are missing, select exactly ONE appropriate label for the missing category.
6. Identify other applicable labels based on the issue content, such as status/*, help wanted, good first issue, etc.
7. Give me a single short explanation about why you are selecting each label in the process.
8. Output a JSON array of objects, each containing the issue number
and the labels to add and remove, along with an explanation. For example:
```
[
{
"issue_number": 123,
"labels_to_add": ["area/core", "kind/bug", "priority/p2"],
"labels_to_remove": ["status/need-triage"],
"explanation": "This issue is a UI bug that needs to be addressed with medium priority."
}
]
```
If an issue cannot be classified, do not include it in the output array.
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
## Guidelines
- Output only valid JSON format
- Do not include any explanation or additional text, just the JSON
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content.
- Do not remove the following labels maintainer, help wanted or good first issue.
- Triage only the current issue.
- Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue.
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines (Priority):
P0 - Urgent Blocking Issues:
- Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability.
- Note: You must apply status/manual-triage instead of priority/p0.
P1 - Critical but Workable:
- Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use.
P2 - Significant Issues:
- Definition: Affect some workflows but a clear workaround exists, or non-critical bugs. Examples: Theme flickering, confusing error messages, minor UI misalignment, failing to read deeply nested config files correctly.
P3 - Minor/Enhancements:
- Definition: Trivial bugs, typos, documentation requests, or feature requests.
Categorization Guidelines (Kind):
kind/bug: The issue is describing an unexpected behavior or failure in the application.
kind/enhancement: The issue is describing a feature request or an improvement to an existing feature.
kind/question: The issue is asking a question about how to use the CLI or about a specific feature.
Categorization Guidelines (Area):
area/agent: The "brain" of the CLI. Core agent logic, model quality, tool/function calling, memory, web search, generated code quality, sub-agents.
area/core: The fundamental CLI app. UI/UX, installation, OS compatibility, performance, command parsing, theming, flickering.
area/documentation: Website docs, READMEs, inline help text.
area/enterprise: Telemetry, Policy, Quota / Licensing
area/extensions: Gemini CLI extensions capability
area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation
area/platform: Platform specific behavior
area/security: Authentication, authorization, privacy, data leaks, credential storage.
- name: 'Stop Telemetry Collector'
if: |-
steps.find_issues.outputs.has_effort_issues == 'true'
run: 'docker rm -f gemini-telemetry-collector || true'
- name: 'Run Effort Triage Analysis'
if: |-
steps.find_issues.outputs.has_effort_issues == 'true'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
id: 'gemini_effort_issue_analysis'
id: 'gemini_issue_analysis'
env:
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
REPOSITORY: '${{ github.repository }}'
@@ -304,30 +166,57 @@ jobs:
prompt: |-
## Role
You are an expert software architect. Analyze the provided GitHub issues and assign the correct `effort/*` label based on the codebase complexity.
You are an issue triage assistant. Analyze issues and identify
appropriate labels. Use the available tools to gather information;
do not ask for information to be provided.
## Steps
1. Use the read_file tool to read "effort_issues_to_triage.json".
2. For each issue in the array:
- You must evaluate the architectural complexity to determine the effort level. You MUST NOT guess the root cause. You MUST actively use your codebase search tools (grep_search and glob) to search for keywords from the issue and explore the codebase. You must identify the specific files and components involved before deciding the effort.
3. Output a JSON array of objects, each containing the issue number and the effort label to add, along with an explanation and an effort_analysis field. This effort_analysis must be highly detailed, technical, and empirical. It MUST NOT contain vague guesses (e.g., avoid words like "likely points to" or "possibly"). You must explicitly cite the specific file paths and architectural mechanisms you discovered using your search tools, explain the root cause, and then explicitly state how that complexity maps to the chosen effort level guidelines. For example:
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
2. Use the read_file tool to read the file "issues_to_triage.json" which contains the JSON array of issues to triage.
3. Review the issue title, body and any comments provided in the JSON file.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, priority/*, and effort/*.
5. Label Policy:
- If the issue already has a kind/ label, do not change it.
- If the issue already has a priority/ label, do not change it.
- If the issue already has an area/ label, do not change it.
- If the issue already has an effort/ label, do not change it.
- If the issue is missing an effort/ label AND its area is area/core, area/extensions, area/site, or area/non-interactive, you must evaluate the architectural complexity to determine the effort level. You MUST NOT guess the root cause. You MUST actively use your codebase search tools (grep_search and glob) to search for keywords from the issue and explore the codebase. You must identify the specific files and components involved before deciding the effort. Do NOT evaluate or assign an effort/ label to issues in any other areas (such as area/agent).
- If any of these are missing, select exactly ONE appropriate label for the missing category.
6. Identify other applicable labels based on the issue content, such as status/*, help wanted, good first issue, etc.
7. Give me a single short explanation about why you are selecting each label in the process.
8. Output a JSON array of objects, each containing the issue number
and the labels to add and remove, along with an explanation. If you assigned an effort/ label, you MUST also include an effort_analysis field. This effort_analysis must be highly detailed, technical, and empirical. It MUST NOT contain vague guesses (e.g., avoid words like "likely points to" or "possibly"). You must explicitly cite the specific file paths and architectural mechanisms you discovered using your search tools, explain the root cause, and then explicitly state how that complexity maps to the chosen effort level guidelines. For example:
```
[
{
"issue_number": 123,
"labels_to_add": ["effort/small"],
"explanation": "This is a simple logic fix.",
"labels_to_add": ["area/core", "kind/bug", "priority/p2", "effort/small"],
"labels_to_remove": ["status/need-triage"],
"explanation": "This issue is a UI bug that needs to be addressed with medium priority.",
"effort_analysis": "The `vscode-ide-companion` extension indiscriminately tracks active text editors via `vscode.window.onDidChangeActiveTextEditor` in `open-files-manager.ts`. When a user opens `.vscode/settings.json`, its content is sent to the CLI's context. The fix is highly localized to the VS Code companion extension's event listener. It involves adding a simple conditional check to exclude specific configuration files from the active editor tracking logic, which is a trivial logic adjustment with a clear root cause."
}
]
```
If an issue cannot be classified, do not include it in the output array.
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
## Guidelines
- Output only valid JSON format
- Do not include any explanation or additional text, just the JSON
- Triage only the current issue.
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content.
- Do not remove the following labels maintainer, help wanted or good first issue.
- Triage only the current issue.
- Identify only one area/ label.
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Identify only one priority/ label.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines (Effort):
effort/small (1 day or less):
@@ -383,29 +272,13 @@ jobs:
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
- When users report that they dont expect the model to change those would be categorized as feature requests.
- name: 'Apply Standard Labels to Issues'
- name: 'Apply Labels to Issues'
if: |-
${{ steps.gemini_standard_issue_analysis.outcome == 'success' &&
steps.gemini_standard_issue_analysis.outputs.summary != '[]' &&
steps.gemini_standard_issue_analysis.outputs.summary != '' }}
${{ steps.gemini_issue_analysis.outcome == 'success' &&
steps.gemini_issue_analysis.outputs.summary != '[]' }}
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
await applyLabels({ github, context, core });
- name: 'Apply Effort Labels to Issues'
if: |-
${{ steps.gemini_effort_issue_analysis.outcome == 'success' &&
steps.gemini_effort_issue_analysis.outputs.summary != '[]' &&
steps.gemini_effort_issue_analysis.outputs.summary != '' }}
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
LABELS_OUTPUT: '${{ steps.gemini_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -21,8 +21,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Generate GitHub App Token'
id: 'generate_token'
@@ -19,8 +19,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
persist-credentials: false
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -43,8 +41,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
persist-credentials: false
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
-2
View File
@@ -17,8 +17,6 @@ jobs:
runs-on: 'ubuntu-latest'
steps:
- uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Link Checker'
id: 'lychee'
-2
View File
@@ -16,8 +16,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
-2
View File
@@ -16,8 +16,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
persist-credentials: false
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -44,7 +44,6 @@ jobs:
with:
ref: '${{ github.ref }}'
fetch-depth: 0
persist-credentials: false
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
-2
View File
@@ -65,13 +65,11 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
fetch-depth: 0
- name: 'Checkout Release Code'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
path: 'release'
fetch-depth: 0
-2
View File
@@ -50,13 +50,11 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
fetch-depth: 0
- name: 'Checkout Release Code'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
path: 'release'
fetch-depth: 0
-1
View File
@@ -31,7 +31,6 @@ jobs:
- name: 'Checkout repository'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
persist-credentials: false
# The user-level skills need to be available to the workflow
fetch-depth: 0
ref: 'main'
@@ -17,7 +17,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
fetch-depth: 1
- name: 'Slash Command Dispatch'
@@ -54,7 +54,6 @@ jobs:
with:
ref: '${{ github.event.inputs.ref }}'
fetch-depth: 0
persist-credentials: false
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
@@ -64,7 +64,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: "${{ github.event.inputs.workflow_ref || 'main' }}"
fetch-depth: 1
@@ -53,14 +53,12 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: 'Checkout Release Code'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.release_ref }}'
path: 'release'
fetch-depth: 0
-8
View File
@@ -55,7 +55,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
@@ -172,13 +171,11 @@ jobs:
- name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
- name: 'Checkout correct SHA'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ matrix.sha }}'
path: 'release'
fetch-depth: 0
@@ -219,13 +216,11 @@ jobs:
- name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
- name: 'Checkout correct SHA'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}'
path: 'release'
fetch-depth: 0
@@ -293,13 +288,11 @@ jobs:
- name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
- name: 'Checkout correct SHA'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ needs.calculate-versions.outputs.STABLE_SHA }}'
path: 'release'
fetch-depth: 0
@@ -367,7 +360,6 @@ jobs:
- name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
- name: 'Setup Node.js'
-1
View File
@@ -52,7 +52,6 @@ jobs:
- name: 'Checkout repository'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref }}'
fetch-depth: 0
-1
View File
@@ -26,7 +26,6 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Push'
-1
View File
@@ -32,7 +32,6 @@ jobs:
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
persist-credentials: false
- name: 'Install Dependencies'
run: 'npm ci'
- name: 'Build bundle'
-2
View File
@@ -34,8 +34,6 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
with:
persist-credentials: false
- name: 'Optimize Windows Performance'
if: "matrix.os == 'windows-latest'"
-2
View File
@@ -44,8 +44,6 @@ jobs:
shell: 'bash'
run: 'echo "${{ toJSON(vars) }}"'
- uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
persist-credentials: false
- name: 'Verify release'
uses: './.github/actions/verify-release'
with:
+1 -5
View File
@@ -43,13 +43,9 @@
{
"type": "node",
"request": "launch",
"name": "CLI: Run Current File",
"runtimeExecutable": "node",
"runtimeArgs": ["--import", "tsx"],
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${file}",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"outFiles": ["${workspaceFolder}/**/*.js"]
},
{
-22
View File
@@ -1043,28 +1043,6 @@ describe('loadCliConfig', () => {
expect(config.isInteractive()).toBe(false);
});
describe('isAcpMode', () => {
it('should force skipNextSpeakerCheck to true when in ACP mode', async () => {
process.argv = ['node', 'script.js', '--acp'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
model: { skipNextSpeakerCheck: false },
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSkipNextSpeakerCheck()).toBe(true);
});
it('should respect settings.model.skipNextSpeakerCheck when not in ACP mode', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
model: { skipNextSpeakerCheck: false },
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSkipNextSpeakerCheck()).toBe(false);
});
});
});
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
+1 -5
View File
@@ -1105,11 +1105,7 @@ export async function loadCliConfig(
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
// In ACP mode, always skip the next-speaker check. This check triggers
// recursive continuation turns inside GeminiClient.processTurn() that
// conflict with ACP's explicit turn management via session/prompt,
// causing infinite agent_thought_chunk loops.
skipNextSpeakerCheck: isAcpMode || settings.model?.skipNextSpeakerCheck,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
+1 -2
View File
@@ -1300,8 +1300,7 @@ export async function inferInstallMetadata(
source.startsWith('git@') ||
source.startsWith('sso://') ||
source.startsWith('github:') ||
source.startsWith('gitlab:') ||
source.startsWith('ssh://')
source.startsWith('gitlab:')
) {
return {
source,
-33
View File
@@ -1189,39 +1189,6 @@ describe('resolveSessionId', () => {
expect(sessionId).toBe('new-id');
expect(resumedSessionData).toBeUndefined();
});
it('should exit with FATAL_INPUT_ERROR when explicit resume session is missing', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(SessionError.noSessionsFound()),
}) as unknown as InstanceType<typeof SessionSelector>,
);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
try {
await resolveSessionId('explicit-session-id');
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'error',
expect.stringContaining('Error resuming session:'),
);
expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
emitFeedbackSpy.mockRestore();
processExitSpy.mockRestore();
});
});
describe('gemini.tsx main function exit codes', () => {
+3 -9
View File
@@ -85,11 +85,7 @@ import { validateAuthMethod } from './config/auth.js';
import { runAcpClient } from './acp/acpStdioTransport.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { appEvents, AppEvent } from './utils/events.js';
import {
RESUME_LATEST,
SessionError,
SessionSelector,
} from './utils/sessionUtils.js';
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
import { relaunchOnExitCode } from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
@@ -313,10 +309,8 @@ export async function resolveSessionId(
};
} catch (error) {
if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') {
if (resumeArg === RESUME_LATEST) {
coreEvents.emitFeedback('warning', error.message);
return { sessionId: createSessionId() };
}
coreEvents.emitFeedback('warning', error.message);
return { sessionId: createSessionId() };
}
coreEvents.emitFeedback(
'error',
-3
View File
@@ -68,9 +68,6 @@ export async function runNonInteractive(
): Promise<void> {
const useAgentSession = params.config.getAgentSessionNoninteractiveEnabled();
if (useAgentSession) {
debugLogger.debug(
'[ADK] Running non-interactive mode with ADK agent session',
);
return runNonInteractiveAgentSession(params);
}
-36
View File
@@ -1267,42 +1267,6 @@ describe('AppContainer State Management', () => {
});
});
describe('SessionStart Hook Rendering', () => {
it('does not render systemMessage directly (avoids duplicate with HookSystemMessage event)', async () => {
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
const fireSessionStartEvent = vi.fn().mockResolvedValue({
systemMessage: 'Hello from SessionStart hook',
getAdditionalContext: vi.fn(() => undefined),
});
vi.spyOn(mockConfig, 'getHookSystem').mockReturnValue({
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent,
} as unknown as ReturnType<Config['getHookSystem']>);
const { unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(fireSessionStartEvent).toHaveBeenCalled());
// The direct-render path (the bug) would call addItem with the
// systemMessage text and no `source` field. The HookSystemMessage
// event-listener path (the correct one) always sets `source`.
const directRenderCall = mockAddItem.mock.calls.find(
([item]) =>
item?.text === 'Hello from SessionStart hook' && !item?.source,
);
expect(directRenderCall).toBeUndefined();
unmount();
});
});
describe('Token Counting from Session Stats', () => {
it('tracks token counts from session messages', async () => {
// Session stats are provided through the SessionStatsProvider context
+16
View File
@@ -497,6 +497,16 @@ export const AppContainer = (props: AppContainerProps) => {
?.fireSessionStartEvent(sessionStartSource);
if (result) {
if (result.systemMessage) {
historyManager.addItem(
{
type: MessageType.INFO,
text: result.systemMessage,
},
Date.now(),
);
}
const additionalContext = result.getAdditionalContext();
const geminiClient = config.getGeminiClient();
if (additionalContext && geminiClient) {
@@ -539,6 +549,12 @@ export const AppContainer = (props: AppContainerProps) => {
debugLogger.error('Error during cleanup:', e),
);
};
// Disable the dependencies check here. historyManager gets flagged
// but we don't want to react to changes to it because each new history
// item, including the ones from the start session hook will cause a
// re-render and an error when we try to reload config.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, resumedSessionData]);
useEffect(
@@ -5332,34 +5332,6 @@ describe('InputPrompt', () => {
});
});
});
describe('terminal buffer rendering', () => {
it('does not clip the last char of a visual line whose width equals inputWidth', async () => {
const fullLine = '1234567890'; // 10 chars, exactly props.inputWidth
props.inputWidth = 10;
props.suggestionsWidth = 10;
vi.spyOn(props.config, 'getUseTerminalBuffer').mockReturnValue(true);
mockBuffer.text = fullLine;
mockBuffer.lines = [fullLine];
mockBuffer.allVisualLines = [fullLine];
mockBuffer.viewportVisualLines = [fullLine];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualToTransformedMap = [0];
mockBuffer.transformationsByLine = [[]];
mockBuffer.cursor = [0, fullLine.length];
mockBuffer.visualCursor = [0, fullLine.length];
const { lastFrame, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{ uiActions },
);
await waitFor(() => {
expect(clean(lastFrame())).toContain(fullLine);
});
unmount();
});
});
});
function clean(str: string | undefined): string {
@@ -93,8 +93,6 @@ import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
const SCROLLBAR_GUTTER_WIDTH = 1;
/**
* Returns if the terminal can be trusted to handle paste events atomically
* rather than potentially sending multiple paste events separated by line
@@ -1870,7 +1868,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
width={inputWidth + SCROLLBAR_GUTTER_WIDTH}
width={inputWidth}
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
buffer.viewportHeight,
@@ -562,13 +562,6 @@ export const useSessionBrowserInput = (
state.setActiveIndex(0);
state.setScrollOffset(0);
return true;
} else if (key.name === 'enter') {
const selectedSession =
state.filteredAndSortedSessions[state.activeIndex];
if (selectedSession && !selectedSession.isCurrentSession) {
onResumeSession(selectedSession);
}
return true;
} else if (
key.sequence &&
key.sequence.length === 1 &&
@@ -141,7 +141,7 @@ describe('ToolConfirmationQueue', () => {
expect(output).toContain('1 of 3');
expect(output).toContain('ls'); // Tool name
expect(output).toContain('list files'); // Tool description
expect(output).toContain('Allow execution of [Shell]?');
expect(output).toContain('Allow execution of [ls]?');
expect(output).toMatchSnapshot();
unmount();
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> &gt; hello </text>
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -60,12 +60,6 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 2`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello 👍 world
@@ -174,27 +168,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
@@ -84,8 +84,8 @@
<text x="711" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs"> Allow execution of </text>
<text x="189" y="308" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="252" y="308" fill="#ffffff" textLength="459" lengthAdjust="spacingAndGlyphs">? </text>
<text x="189" y="308" fill="#ffffaf" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">[echo]</text>
<text x="243" y="308" fill="#ffffff" textLength="468" lengthAdjust="spacingAndGlyphs">? </text>
<text x="711" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="711" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -191,8 +191,8 @@
<text x="711" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="563" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs"> Allow execution of </text>
<text x="189" y="563" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="252" y="563" fill="#ffffff" textLength="459" lengthAdjust="spacingAndGlyphs">? </text>
<text x="189" y="563" fill="#ffffaf" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">[echo]</text>
<text x="243" y="563" fill="#ffffff" textLength="468" lengthAdjust="spacingAndGlyphs">? </text>
<text x="711" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="711" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -52,7 +52,7 @@ exports[`ToolConfirmationQueue > height allocation and layout > should handle se
│ Original: https://täst.com/ │
│ Actual Host (Punycode): https://xn--tst-qla.com/ │
│ │
│ Allow execution of [Shell]? │
│ Allow execution of [echo]?
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
@@ -138,7 +138,7 @@ exports[`ToolConfirmationQueue > height allocation and layout > should render th
│ │ echo "Line 49" │ │
│ │ echo "Line 50" │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ Allow execution of [Shell]? │
│ Allow execution of [echo]?
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
@@ -202,7 +202,7 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ ls │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ Allow execution of [Shell]?
│ Allow execution of [ls]?
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
@@ -6,11 +6,7 @@
import { waitFor } from '../../../test-utils/async.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import {
Kind,
CoreToolCallStatus,
SubagentState,
} from '@google/gemini-cli-core';
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
@@ -31,12 +27,12 @@ describe('<SubagentGroupDisplay />', () => {
resultDisplay: {
isSubagentProgress: true,
agentName: 'api-monitor',
state: SubagentState.RUNNING,
state: 'running',
recentActivity: [
{
id: 'act-1',
type: 'tool_call',
status: SubagentState.RUNNING,
status: 'running',
content: '',
displayName: 'Action Required',
description: 'Verify server is running',
@@ -54,13 +50,13 @@ describe('<SubagentGroupDisplay />', () => {
resultDisplay: {
isSubagentProgress: true,
agentName: 'db-manager',
state: SubagentState.COMPLETED,
state: 'completed',
result: 'Database schema validated',
recentActivity: [
{
id: 'act-2',
type: 'thought',
status: SubagentState.COMPLETED,
status: 'completed',
content: 'Database schema validated',
},
],
@@ -13,7 +13,6 @@ import {
isSubagentProgress,
checkExhaustive,
type SubagentActivityItem,
SubagentState,
} from '@google/gemini-cli-core';
import {
SubagentProgressDisplay,
@@ -67,13 +66,13 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
const singleAgent = toolCalls[0].resultDisplay;
if (isSubagentProgress(singleAgent)) {
switch (singleAgent.state) {
case SubagentState.COMPLETED:
case 'completed':
headerText = 'Agent Completed';
break;
case SubagentState.CANCELLED:
case 'cancelled':
headerText = 'Agent Cancelled';
break;
case SubagentState.ERROR:
case 'error':
headerText = 'Agent Error';
break;
default:
@@ -89,8 +88,8 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
for (const tc of toolCalls) {
const progress = tc.resultDisplay;
if (isSubagentProgress(progress)) {
if (progress.state === SubagentState.COMPLETED) completedCount++;
else if (progress.state === SubagentState.RUNNING) runningCount++;
if (progress.state === 'completed') completedCount++;
else if (progress.state === 'running') runningCount++;
} else {
// It hasn't emitted progress yet, but it is "running"
runningCount++;
@@ -201,7 +200,7 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
let content = 'Starting...';
let formattedArgs: string | undefined;
if (progress.state === SubagentState.COMPLETED) {
if (progress.state === 'completed') {
if (
progress.terminateReason &&
progress.terminateReason !== 'GOAL'
@@ -224,18 +223,18 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
}
const displayArgs =
progress.state === SubagentState.COMPLETED ? '' : formattedArgs;
progress.state === 'completed' ? '' : formattedArgs;
const renderStatusIcon = () => {
const state = progress.state ?? SubagentState.RUNNING;
const state = progress.state ?? 'running';
switch (state) {
case SubagentState.RUNNING:
case 'running':
return <Text color={theme.text.primary}>!</Text>;
case SubagentState.COMPLETED:
case 'completed':
return <Text color={theme.status.success}></Text>;
case SubagentState.CANCELLED:
case 'cancelled':
return <Text color={theme.status.warning}></Text>;
case SubagentState.ERROR:
case 'error':
return <Text color={theme.status.error}></Text>;
default:
return checkExhaustive(state);
@@ -8,7 +8,6 @@ import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { SubagentHistoryMessage } from './SubagentHistoryMessage.js';
import type { HistoryItemSubagent } from '../../types.js';
import { SubagentState } from '@google/gemini-cli-core';
describe('SubagentHistoryMessage', () => {
const mockItem: HistoryItemSubagent = {
@@ -19,19 +18,19 @@ describe('SubagentHistoryMessage', () => {
id: '1',
type: 'thought',
content: 'Thinking about the problem',
status: SubagentState.COMPLETED,
status: 'completed',
},
{
id: '2',
type: 'tool_call',
content: 'Calling search_web',
status: SubagentState.RUNNING,
status: 'running',
},
{
id: '3',
type: 'tool_call',
content: 'Calling read_file fail',
status: SubagentState.ERROR,
status: 'error',
},
],
};
@@ -6,7 +6,7 @@
import { render, cleanup } from '../../../test-utils/render.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
import { type SubagentProgress, SubagentState } from '@google/gemini-cli-core';
import type { SubagentProgress } from '@google/gemini-cli-core';
import { describe, it, expect, vi, afterEach } from 'vitest';
describe('<SubagentProgressDisplay />', () => {
@@ -25,7 +25,7 @@ describe('<SubagentProgressDisplay />', () => {
type: 'tool_call',
content: 'run_shell_command',
args: '{"command": "echo hello", "description": "Say hello"}',
status: SubagentState.RUNNING,
status: 'running',
},
],
};
@@ -48,7 +48,7 @@ describe('<SubagentProgressDisplay />', () => {
displayName: 'RunShellCommand',
description: 'Executing echo hello',
args: '{"command": "echo hello"}',
status: SubagentState.RUNNING,
status: 'running',
},
],
};
@@ -69,7 +69,7 @@ describe('<SubagentProgressDisplay />', () => {
type: 'tool_call',
content: 'run_shell_command',
args: '{"command": "echo hello"}',
status: SubagentState.RUNNING,
status: 'running',
},
],
};
@@ -90,7 +90,7 @@ describe('<SubagentProgressDisplay />', () => {
type: 'tool_call',
content: 'write_file',
args: '{"file_path": "/tmp/test.txt", "content": "foo"}',
status: SubagentState.COMPLETED,
status: 'completed',
},
],
};
@@ -113,7 +113,7 @@ describe('<SubagentProgressDisplay />', () => {
type: 'tool_call',
content: 'run_shell_command',
args: JSON.stringify({ description: longDesc }),
status: SubagentState.RUNNING,
status: 'running',
},
],
};
@@ -133,7 +133,7 @@ describe('<SubagentProgressDisplay />', () => {
id: '5',
type: 'thought',
content: 'Thinking about life',
status: SubagentState.RUNNING,
status: 'running',
},
],
};
@@ -149,7 +149,7 @@ describe('<SubagentProgressDisplay />', () => {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [],
state: SubagentState.CANCELLED,
state: 'cancelled',
};
const { lastFrame } = await render(
@@ -167,7 +167,7 @@ describe('<SubagentProgressDisplay />', () => {
id: '6',
type: 'thought',
content: 'Request cancelled.',
status: SubagentState.ERROR,
status: 'error',
},
],
};
@@ -188,7 +188,7 @@ describe('<SubagentProgressDisplay />', () => {
type: 'tool_call',
content: 'run_shell_command',
args: '{"command": "echo hello"}',
status: SubagentState.ERROR,
status: 'error',
},
],
};
@@ -9,10 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import Spinner from 'ink-spinner';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import {
type SubagentProgress,
type SubagentActivityItem,
SubagentState,
import type {
SubagentProgress,
SubagentActivityItem,
} from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
@@ -63,13 +62,13 @@ export const SubagentProgressDisplay: React.FC<
let headerText: string | undefined;
let headerColor = theme.text.secondary;
if (progress.state === SubagentState.CANCELLED) {
if (progress.state === 'cancelled') {
headerText = `Subagent ${progress.agentName} was cancelled.`;
headerColor = theme.status.warning;
} else if (progress.state === SubagentState.ERROR) {
} else if (progress.state === 'error') {
headerText = `Subagent ${progress.agentName} failed.`;
headerColor = theme.status.error;
} else if (progress.state === SubagentState.COMPLETED) {
} else if (progress.state === 'completed') {
headerText = `Subagent ${progress.agentName} completed.`;
headerColor = theme.status.success;
} else {
@@ -108,13 +107,13 @@ export const SubagentProgressDisplay: React.FC<
);
} else if (item.type === 'tool_call') {
const statusSymbol =
item.status === SubagentState.RUNNING ? (
item.status === 'running' ? (
<Spinner type="dots" />
) : item.status === SubagentState.COMPLETED ? (
) : item.status === 'completed' ? (
<Text color={theme.status.success}>
{TOOL_STATUS.SUCCESS}
</Text>
) : item.status === SubagentState.CANCELLED ? (
) : item.status === 'cancelled' ? (
<Text color={theme.status.warning} bold>
{TOOL_STATUS.CANCELED}
</Text>
@@ -136,7 +135,7 @@ export const SubagentProgressDisplay: React.FC<
<Text
bold
color={theme.text.primary}
strikethrough={item.status === SubagentState.CANCELLED}
strikethrough={item.status === 'cancelled'}
>
{item.displayName || item.content}
</Text>
@@ -145,9 +144,7 @@ export const SubagentProgressDisplay: React.FC<
<Text
color={theme.text.secondary}
wrap="truncate"
strikethrough={
item.status === SubagentState.CANCELLED
}
strikethrough={item.status === 'cancelled'}
>
{displayArgs}
</Text>
@@ -173,7 +170,7 @@ export const SubagentProgressDisplay: React.FC<
)}
<MarkdownDisplay
text={safeJsonToMarkdown(progress.result)}
isPending={progress.state !== SubagentState.COMPLETED}
isPending={progress.state !== 'completed'}
terminalWidth={terminalWidth}
/>
</Box>
@@ -275,108 +275,6 @@ describe('ToolConfirmationMessage', () => {
result.unmount();
});
it('should use the tool name for display in the confirmation question', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: '# This is a comment\necho "hello"',
rootCommand: 'echo',
rootCommands: ['echo'],
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName="shell"
/>,
);
const output = lastFrame();
expect(output).toContain('Allow execution of [Shell]?');
unmount();
});
describe('tool name humanization', () => {
const cases = [
{
toolName: 'run_shell_command',
expected: 'Allow execution of [Shell]?',
desc: 'humanize run_shell_command to Shell',
},
{
toolName: 'shell',
expected: 'Allow execution of [Shell]?',
desc: 'humanize shell to Shell',
},
{
toolName: 'grep_search',
expected: 'Allow execution of [grep_search]?',
desc: 'keep raw name for non-shell tools',
},
];
for (const { toolName, expected, desc } of cases) {
it(`should ${desc}`, async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName={toolName}
/>,
);
expect(lastFrame()).toContain(expected);
unmount();
});
}
it('should humanize shell tool in sandbox expansion prompt', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'sandbox_expansion',
title: 'Confirm',
command: 'ls',
rootCommand: 'ls',
additionalPermissions: {
network: true,
},
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName="run_shell_command"
/>,
);
expect(lastFrame()).toContain(
'To run [Shell], allow access to the following?',
);
unmount();
});
});
describe('with folder trust', () => {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
@@ -629,13 +629,18 @@ export const ToolConfirmationMessage: React.FC<
);
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
const { additionalPermissions, command } = confirmationDetails;
const { additionalPermissions, command, rootCommand } =
confirmationDetails;
const readPaths = additionalPermissions?.fileSystem?.read || [];
const writePaths = additionalPermissions?.fileSystem?.write || [];
const network = additionalPermissions?.network;
const isShell = isShellTool(toolName);
const commandNames = isShell ? 'Shell' : toolName;
const rootCmds = rootCommand
.split(',')
.map((c) => c.trim().split(/\s+/)[0])
.filter((c) => c && !c.startsWith('redirection'));
const commandNames = Array.from(new Set(rootCmds)).join(', ');
question = '';
bodyContent = (
@@ -725,7 +730,13 @@ export const ToolConfirmationMessage: React.FC<
);
}
const commandNames = isShell ? 'Shell' : toolName;
const commandNames = Array.from(
new Set(
commandsToDisplay
.map((cmd) => cmd.trim().split(/\s+/)[0])
.filter(Boolean),
),
).join(', ');
const allowQuestion = (
<Text>
@@ -13,7 +13,6 @@ import {
ApprovalMode,
WRITE_FILE_DISPLAY_NAME,
Kind,
SubagentState,
} from '@google/gemini-cli-core';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
@@ -77,7 +76,7 @@ describe('ToolGroupMessage Regression Tests', () => {
resultDisplay: {
isSubagentProgress: true,
agentName: 'TestAgent',
state: SubagentState.RUNNING,
state: 'running',
recentActivity: [],
},
}),
@@ -113,7 +112,7 @@ describe('ToolGroupMessage Regression Tests', () => {
resultDisplay: {
isSubagentProgress: true,
agentName: 'TestAgent',
state: SubagentState.COMPLETED,
state: 'completed',
recentActivity: [],
},
}),
@@ -32,12 +32,10 @@ export const STATUS_INDICATOR_WIDTH = 3;
* Returns true if the tool name corresponds to a shell tool.
*/
export function isShellTool(name: string): boolean {
const normalized = name.toLowerCase();
return (
name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME ||
normalized === 'shell'
name === SHELL_TOOL_NAME
);
}
@@ -4,7 +4,7 @@ exports[`ToolConfirmationMessage Redirection > should display redirection warnin
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" > test.txt │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Allow execution of [Shell]?
Allow execution of [echo]?
Redirection detected. To auto-accept, press Shift+Tab
● 1. Allow once
@@ -133,9 +133,7 @@
<text x="63" y="546" fill="#cdcd00" textLength="81" lengthAdjust="spacingAndGlyphs">&quot;Line 50&quot;</text>
<text x="711" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="580" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs">Allow execution of </text>
<text x="171" y="580" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="234" y="580" fill="#ffffff" textLength="666" lengthAdjust="spacingAndGlyphs">? </text>
<text x="0" y="580" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of [echo]? </text>
<rect x="0" y="612" width="9" height="17" fill="#001a00" />
<text x="0" y="614" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="612" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -24,9 +24,7 @@
<text x="18" y="70" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
<text x="711" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="104" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs">Allow execution of </text>
<text x="171" y="104" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="234" y="104" fill="#ffffff" textLength="666" lengthAdjust="spacingAndGlyphs">? </text>
<text x="0" y="104" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of [echo]? </text>
<rect x="0" y="136" width="9" height="17" fill="#001a00" />
<text x="0" y="138" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="136" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -94,7 +94,7 @@ exports[`ToolConfirmationMessage > height allocation and layout > should expand
│ echo "Line 49" │
│ echo "Line 50" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [Shell]?
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
@@ -110,7 +110,7 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
│ │
│ whoami │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [Shell]?
Allow execution of [echo, ls, whoami]?
● 1. Allow once
2. Allow for this session
@@ -148,7 +148,7 @@ exports[`ToolConfirmationMessage > should render multiline shell scripts with co
│ echo $i │
│ done │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [Shell]?
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
@@ -200,7 +200,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [Shell]?
Allow execution of [echo]?
● 1. Allow once
2. No, suggest changes (esc)
@@ -211,7 +211,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [Shell]?
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
@@ -21,7 +21,6 @@ import {
ROOT_SCHEDULER_ID,
CoreToolCallStatus,
type WaitingToolCall,
SubagentState,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
@@ -631,7 +630,7 @@ describe('useToolScheduler', () => {
id: '1',
type: 'thought',
content: 'Thinking...',
status: SubagentState.RUNNING,
status: 'running',
},
});
});
@@ -649,7 +648,7 @@ describe('useToolScheduler', () => {
id: '2',
type: 'tool_call',
content: 'Calling tool',
status: SubagentState.COMPLETED,
status: 'completed',
},
});
});
@@ -698,7 +697,7 @@ describe('useToolScheduler', () => {
id: '1',
type: 'thought',
content: 'Thinking...',
status: SubagentState.RUNNING,
status: 'running',
},
});
});
@@ -717,7 +716,7 @@ describe('useToolScheduler', () => {
id: '1',
type: 'thought',
content: 'Thinking... Done!',
status: SubagentState.COMPLETED,
status: 'completed',
},
});
});
@@ -727,8 +726,6 @@ describe('useToolScheduler', () => {
expect(result.current[0][0].subagentHistory![0].content).toBe(
'Thinking... Done!',
);
expect(result.current[0][0].subagentHistory![0].status).toBe(
SubagentState.COMPLETED,
);
expect(result.current[0][0].subagentHistory![0].status).toBe('completed');
});
});
-114
View File
@@ -616,120 +616,6 @@ describe('SessionSelector', () => {
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(mainSessionId);
});
it('should list legacy session JSON without timestamps (regression #18593)', async () => {
const sessionId = randomUUID();
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const session = {
sessionId,
projectHash: 'test-hash',
messages: [
{
type: 'user',
content: 'Legacy session message',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
};
const filePath = path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
);
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z');
await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp);
const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(sessionId);
expect(sessions[0].startTime).toBe(fallbackTimestamp.toISOString());
expect(sessions[0].lastUpdated).toBe(fallbackTimestamp.toISOString());
});
it('should resolve legacy session JSON without timestamps by UUID (regression #18593)', async () => {
const sessionId = randomUUID();
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const session = {
sessionId,
projectHash: 'test-hash',
messages: [
{
type: 'user',
content: 'Legacy session message',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
};
const filePath = path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
);
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z');
await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp);
const sessionSelector = new SessionSelector(storage);
const result = await sessionSelector.resolveSession(sessionId);
expect(result.sessionData.sessionId).toBe(sessionId);
expect(result.sessionData.startTime).toBe(fallbackTimestamp.toISOString());
expect(result.sessionData.lastUpdated).toBe(
fallbackTimestamp.toISOString(),
);
});
it('should throw INVALID_SESSION_IDENTIFIER for a UUID that does not exist on disk at all', async () => {
const existingSessionId = randomUUID();
const nonExistentId = randomUUID();
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const session = {
sessionId: existingSessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:30:00.000Z',
messages: [
{
type: 'user',
content: 'Hello',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
};
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${existingSessionId.slice(0, 8)}.json`,
),
JSON.stringify(session, null, 2),
);
const sessionSelector = new SessionSelector(storage);
await expect(sessionSelector.findSession(nonExistentId)).rejects.toSatisfy(
(error) => {
expect(error).toBeInstanceOf(SessionError);
expect((error as SessionError).code).toBe('INVALID_SESSION_IDENTIFIER');
return true;
},
);
});
});
describe('extractFirstUserMessage', () => {
+8 -21
View File
@@ -270,23 +270,15 @@ export const getAllSessionFiles = async (
}
// Validate required fields
if (!content.sessionId) {
if (
!content.sessionId ||
!content.startTime ||
!content.lastUpdated
) {
// Missing required fields - treat as corrupted
return { fileName: file, sessionInfo: null };
}
const fileTimestamp =
!content.startTime || !content.lastUpdated
? (
await fs.stat(filePath).catch(() => undefined)
)?.mtime.toISOString()
: undefined;
const fallbackTimestamp = fileTimestamp ?? new Date().toISOString();
const startTime =
content.startTime || content.lastUpdated || fallbackTimestamp;
const lastUpdated =
content.lastUpdated || content.startTime || fallbackTimestamp;
// Skip sessions that only contain system messages (info, error, warning)
if (!content.hasUserOrAssistantMessage) {
return { fileName: file, sessionInfo: null };
@@ -327,8 +319,8 @@ export const getAllSessionFiles = async (
id: content.sessionId,
file: file.replace(/\.jsonl?$/, ''),
fileName: file,
startTime,
lastUpdated,
startTime: content.startTime,
lastUpdated: content.lastUpdated,
messageCount: content.messageCount ?? content.messages.length,
displayName: content.summary
? stripUnsafeCharacters(content.summary)
@@ -554,17 +546,12 @@ export class SessionSelector {
if (!sessionData) {
throw new Error('Failed to load session data');
}
const normalizedSessionData = {
...sessionData,
startTime: sessionData.startTime || sessionInfo.startTime,
lastUpdated: sessionData.lastUpdated || sessionInfo.lastUpdated,
};
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
return {
sessionPath,
sessionData: normalizedSessionData,
sessionData,
displayInfo,
};
} catch (error) {
+4 -4
View File
@@ -16,7 +16,7 @@ import type {
AgentInterface,
} from '@a2a-js/sdk';
import type { SendMessageResult } from './a2a-client-manager.js';
import { type SubagentActivityItem, SubagentState } from './types.js';
import type { SubagentActivityItem } 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.`;
@@ -143,7 +143,7 @@ export class A2AResultReassembler {
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: SubagentState.RUNNING,
status: 'running',
});
}
@@ -152,7 +152,7 @@ export class A2AResultReassembler {
id: `msg-${index}`,
type: 'thought',
content: msg.trim(),
status: SubagentState.COMPLETED,
status: 'completed',
});
});
@@ -161,7 +161,7 @@ export class A2AResultReassembler {
id: 'pending',
type: 'thought',
content: 'Working...',
status: SubagentState.RUNNING,
status: 'running',
});
}
@@ -32,7 +32,6 @@ import {
type SubagentActivityItem,
AgentTerminateMode,
isToolActivityError,
SubagentState,
} from '../types.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
@@ -124,7 +123,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.agentName,
recentActivity: [],
state: SubagentState.RUNNING,
state: 'running',
};
updateOutput(initialProgress);
}
@@ -138,7 +137,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
id: randomUUID(),
type: 'thought',
content: sanitizedMsg,
status: SubagentState.COMPLETED,
status: 'completed',
});
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
@@ -147,7 +146,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.agentName,
recentActivity: [...recentActivity],
state: SubagentState.RUNNING,
state: 'running',
} as SubagentProgress);
}
: undefined;
@@ -176,7 +175,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === SubagentState.RUNNING
lastItem.status === 'running'
) {
lastItem.content = sanitizeThoughtContent(text);
} else {
@@ -184,7 +183,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
id: randomUUID(),
type: 'thought',
content: sanitizeThoughtContent(text),
status: SubagentState.RUNNING,
status: 'running',
});
}
updated = true;
@@ -211,7 +210,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
displayName,
description,
args,
status: SubagentState.RUNNING,
status: 'running',
});
updated = true;
break;
@@ -228,11 +227,9 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
recentActivity[i].type === 'tool_call' &&
callId != null &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
recentActivity[i].status === 'running'
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
recentActivity[i].status = isError ? 'error' : 'completed';
updated = true;
break;
}
@@ -245,9 +242,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
const newStatus = isCancellation
? SubagentState.CANCELLED
: SubagentState.ERROR;
const newStatus = isCancellation ? 'cancelled' : 'error';
if (callId) {
// Mark the specific tool as error/cancelled
@@ -255,7 +250,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
recentActivity[i].status === 'running'
) {
recentActivity[i].status = newStatus;
updated = true;
@@ -265,10 +260,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
} else {
// No specific tool — mark ALL running tool_call items
for (const item of recentActivity) {
if (
item.type === 'tool_call' &&
item.status === SubagentState.RUNNING
) {
if (item.type === 'tool_call' && item.status === 'running') {
item.status = newStatus;
updated = true;
}
@@ -301,7 +293,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.agentName,
recentActivity: [...recentActivity],
state: SubagentState.RUNNING,
state: 'running',
};
updateOutput(progress);
}
@@ -338,13 +330,13 @@ ${output.result}`;
// GOAL = agent completed its task normally.
// ABORTED = user cancelled.
// Others (ERROR, MAX_TURNS, ERROR_NO_COMPLETE_TASK_CALL) = error.
let progressState: SubagentState;
let progressState: SubagentProgress['state'];
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
progressState = SubagentState.CANCELLED;
progressState = 'cancelled';
} else if (output.terminate_reason === AgentTerminateMode.GOAL) {
progressState = SubagentState.COMPLETED;
progressState = 'completed';
} else {
progressState = SubagentState.ERROR;
progressState = 'error';
}
const progress: SubagentProgress = {
@@ -374,8 +366,8 @@ ${output.result}`;
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === SubagentState.RUNNING) {
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
if (item.status === 'running') {
item.status = isAbort ? 'cancelled' : 'error';
}
}
@@ -383,7 +375,7 @@ ${output.result}`;
isSubagentProgress: true,
agentName: this.agentName,
recentActivity: [...recentActivity],
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
state: isAbort ? 'cancelled' : 'error',
};
if (updateOutput) {
@@ -4199,49 +4199,6 @@ describe('LocalAgentExecutor', () => {
expect(memoryPart).toBeDefined();
expect(memoryPart?.text).toContain(mockMemory);
});
it('should omit extension context from session memory when disabled by the agent', async () => {
const definition = createTestDefinition();
definition.includeExtensionContext = false;
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const getSessionMemorySpy = vi
.spyOn(mockConfig, 'getSessionMemory')
.mockImplementation(
(options?: { includeExtensionContext?: boolean }) =>
options?.includeExtensionContext === false
? '<loaded_context>\n<project_context>\nProject memory rule\n</project_context>\n</loaded_context>'
: '<loaded_context>\n<extension_context>\nExtension memory rule\n</extension_context>\n<project_context>\nProject memory rule\n</project_context>\n</loaded_context>',
);
vi.spyOn(mockConfig, 'isJitContextEnabled').mockReturnValue(true);
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(getSessionMemorySpy).toHaveBeenCalledWith({
includeExtensionContext: false,
});
const { message } = getMockMessageParams(0);
const parts = message as Part[];
const memoryPart = parts.find((p) =>
p.text?.includes('<loaded_context>'),
);
expect(memoryPart?.text).toContain('Project memory rule');
expect(memoryPart?.text).not.toContain('<extension_context>');
expect(memoryPart?.text).not.toContain('Extension memory rule');
});
});
});
});
+4 -13
View File
@@ -640,19 +640,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
);
const formattedInitialHints = formatUserHintsForModel(initialHints);
// Inject loaded memory files. Some background agents opt out of
// extension memory while still retaining project session context.
let environmentMemory: string;
if (this.context.config.isJitContextEnabled?.()) {
environmentMemory =
this.definition.includeExtensionContext === false
? this.context.config.getSessionMemory({
includeExtensionContext: false,
})
: this.context.config.getSessionMemory();
} else {
environmentMemory = this.context.config.getEnvironmentMemory();
}
// Inject loaded memory files (JIT + extension/project memory)
const environmentMemory = this.context.config.isJitContextEnabled?.()
? this.context.config.getSessionMemory()
: this.context.config.getEnvironmentMemory();
const initialParts: Part[] = [];
if (environmentMemory) {
@@ -21,7 +21,6 @@ import {
type SubagentProgress,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SubagentState,
} from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { LocalAgentExecutor } from './local-executor.js';
@@ -216,7 +215,7 @@ describe('LocalSubagentInvocation', () => {
]);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe(SubagentState.COMPLETED);
expect(display.state).toBe('completed');
expect(display.result).toBe('Analysis complete.');
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
});
@@ -235,7 +234,7 @@ describe('LocalSubagentInvocation', () => {
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe(SubagentState.COMPLETED);
expect(display.state).toBe('completed');
expect(display.result).toBe('Partial progress...');
expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT);
});
@@ -341,7 +340,7 @@ describe('LocalSubagentInvocation', () => {
expect.objectContaining({
type: 'thought',
content: 'Error: Failed',
status: SubagentState.ERROR,
status: 'error',
}),
);
});
@@ -377,7 +376,7 @@ describe('LocalSubagentInvocation', () => {
expect.objectContaining({
type: 'tool_call',
content: 'ls',
status: SubagentState.ERROR,
status: 'error',
}),
);
});
@@ -419,7 +418,7 @@ describe('LocalSubagentInvocation', () => {
expect.objectContaining({
type: 'tool_call',
content: 'ls',
status: SubagentState.CANCELLED,
status: 'cancelled',
}),
);
});
@@ -444,7 +443,7 @@ describe('LocalSubagentInvocation', () => {
expect(result.error).toBeUndefined();
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe(SubagentState.COMPLETED);
expect(display.state).toBe('completed');
expect(display.result).toBe('Done');
});
@@ -467,7 +466,7 @@ describe('LocalSubagentInvocation', () => {
expect.objectContaining({
type: 'thought',
content: `Error: ${error.message}`,
status: SubagentState.ERROR,
status: 'error',
}),
);
});
@@ -489,7 +488,7 @@ describe('LocalSubagentInvocation', () => {
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
content: `Error: ${creationError.message}`,
status: SubagentState.ERROR,
status: 'error',
}),
);
});
+19 -25
View File
@@ -23,7 +23,6 @@ import {
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
isToolActivityError,
SubagentState,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { z } from 'zod';
@@ -118,7 +117,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: SubagentState.RUNNING,
state: 'running',
};
updateOutput(initialProgress);
}
@@ -138,7 +137,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === SubagentState.RUNNING
lastItem.status === 'running'
) {
lastItem.content = sanitizeThoughtContent(text);
} else {
@@ -146,7 +145,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
id: randomUUID(),
type: 'thought',
content: sanitizeThoughtContent(text),
status: SubagentState.RUNNING,
status: 'running',
});
}
updated = true;
@@ -175,7 +174,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
displayName,
description,
args,
status: SubagentState.RUNNING,
status: 'running',
});
updated = true;
@@ -194,11 +193,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === name &&
recentActivity[i].status === SubagentState.RUNNING
recentActivity[i].status === 'running'
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
recentActivity[i].status = isError ? 'error' : 'completed';
updated = true;
this.publishActivity(recentActivity[i]);
@@ -227,9 +224,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === SubagentState.RUNNING
recentActivity[i].status === 'running'
) {
recentActivity[i].status = SubagentState.CANCELLED;
recentActivity[i].status = 'cancelled';
updated = true;
break;
}
@@ -240,9 +237,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === SubagentState.RUNNING
recentActivity[i].status === 'running'
) {
recentActivity[i].status = SubagentState.ERROR;
recentActivity[i].status = 'error';
updated = true;
break;
}
@@ -256,10 +253,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
isCancellation || isRejection
? sanitizedError
: `Error: ${sanitizedError}`,
status:
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR,
status: isCancellation || isRejection ? 'cancelled' : 'error',
});
updated = true;
break;
@@ -273,7 +267,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity], // Copy to avoid mutation issues
state: SubagentState.RUNNING,
state: 'running',
};
updateOutput(progress);
@@ -293,7 +287,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.CANCELLED,
state: 'cancelled',
};
if (updateOutput) {
@@ -309,7 +303,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.COMPLETED,
state: 'completed',
result: output.result,
terminateReason: output.terminate_reason,
};
@@ -340,8 +334,8 @@ ${output.result}`;
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === SubagentState.RUNNING) {
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
if (item.status === 'running') {
item.status = isAbort ? 'cancelled' : 'error';
}
}
@@ -349,12 +343,12 @@ ${output.result}`;
// But only if it's NOT an abort, or if we want to show "Cancelled" as a thought
if (!isAbort) {
const lastActivity = recentActivity[recentActivity.length - 1];
if (!lastActivity || lastActivity.status !== SubagentState.ERROR) {
if (!lastActivity || lastActivity.status !== 'error') {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: `Error: ${errorMessage}`,
status: SubagentState.ERROR,
status: 'error',
});
// Maintain size limit
// No limit on UI events sent via bus
@@ -365,7 +359,7 @@ ${output.result}`;
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
state: isAbort ? 'cancelled' : 'error',
};
if (updateOutput) {
@@ -20,11 +20,7 @@ import {
type A2AClientManager,
} from './a2a-client-manager.js';
import {
type RemoteAgentDefinition,
type SubagentProgress,
SubagentState,
} from './types.js';
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
@@ -272,9 +268,7 @@ describe('RemoteAgentInvocation', () => {
abortSignal: new AbortController().signal,
});
expect(result.returnDisplay).toMatchObject({
state: SubagentState.ERROR,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
"Failed to create auth provider for agent 'test-agent'",
);
@@ -467,7 +461,7 @@ describe('RemoteAgentInvocation', () => {
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: SubagentState.RUNNING,
state: 'running',
recentActivity: expect.arrayContaining([
expect.objectContaining({ content: 'Working...' }),
]),
@@ -476,7 +470,7 @@ describe('RemoteAgentInvocation', () => {
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: SubagentState.COMPLETED,
state: 'completed',
result: 'HelloHello World',
}),
);
@@ -514,9 +508,7 @@ describe('RemoteAgentInvocation', () => {
abortSignal: controller.signal,
});
expect(result.returnDisplay).toMatchObject({
state: SubagentState.ERROR,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
});
it('should handle errors gracefully', async () => {
@@ -541,7 +533,7 @@ describe('RemoteAgentInvocation', () => {
});
expect(result.returnDisplay).toMatchObject({
state: SubagentState.ERROR,
state: 'error',
result: expect.stringContaining('Network error'),
});
});
@@ -624,7 +616,7 @@ describe('RemoteAgentInvocation', () => {
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: SubagentState.RUNNING,
state: 'running',
recentActivity: expect.arrayContaining([
expect.objectContaining({ content: 'Working...' }),
]),
@@ -633,7 +625,7 @@ describe('RemoteAgentInvocation', () => {
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: SubagentState.COMPLETED,
state: 'completed',
result: 'Thinking...Final Answer',
}),
);
@@ -701,7 +693,7 @@ describe('RemoteAgentInvocation', () => {
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: SubagentState.RUNNING,
state: 'running',
recentActivity: expect.arrayContaining([
expect.objectContaining({ content: 'Working...' }),
]),
@@ -710,7 +702,7 @@ describe('RemoteAgentInvocation', () => {
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: SubagentState.COMPLETED,
state: 'completed',
result: 'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
}),
);
@@ -768,9 +760,7 @@ describe('RemoteAgentInvocation', () => {
abortSignal: new AbortController().signal,
});
expect(result.returnDisplay).toMatchObject({
state: SubagentState.ERROR,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
a2aError.userMessage,
);
@@ -792,9 +782,7 @@ describe('RemoteAgentInvocation', () => {
abortSignal: new AbortController().signal,
});
expect(result.returnDisplay).toMatchObject({
state: SubagentState.ERROR,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
'Error calling remote agent: something unexpected',
);
@@ -825,9 +813,7 @@ describe('RemoteAgentInvocation', () => {
abortSignal: new AbortController().signal,
});
expect(result.returnDisplay).toMatchObject({
state: SubagentState.ERROR,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
// Should contain both the partial output and the error message
expect(result.returnDisplay).toMatchObject({
result: expect.stringContaining('Partial response'),
@@ -17,7 +17,6 @@ import {
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
SubagentState,
getAgentCardLoadOptions,
getRemoteAgentTargetUrl,
} from './types.js';
@@ -139,13 +138,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
updateOutput({
isSubagentProgress: true,
agentName,
state: SubagentState.RUNNING,
state: 'running',
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: SubagentState.RUNNING,
status: 'running',
},
],
});
@@ -194,7 +193,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
updateOutput({
isSubagentProgress: true,
agentName,
state: SubagentState.RUNNING,
state: 'running',
recentActivity: reassembler.toActivityItems(),
result: reassembler.toString(),
});
@@ -226,7 +225,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
const finalProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: SubagentState.COMPLETED,
state: 'completed',
result: finalOutput,
recentActivity: reassembler.toActivityItems(),
};
@@ -250,7 +249,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: SubagentState.ERROR,
state: 'error',
result: fullDisplay,
recentActivity: reassembler.toActivityItems(),
};
@@ -28,7 +28,6 @@ import {
DEFAULT_QUERY_STRING,
type RemoteAgentDefinition,
type SubagentProgress,
SubagentState,
getRemoteAgentTargetUrl,
getAgentCardLoadOptions,
} from './types.js';
@@ -234,7 +233,7 @@ class RemoteSubagentProtocol implements AgentProtocol {
this._latestProgress = {
isSubagentProgress: true,
agentName: this._agentName,
state: SubagentState.RUNNING,
state: 'running',
recentActivity: reassembler.toActivityItems(),
result: currentText,
};
@@ -260,7 +259,7 @@ class RemoteSubagentProtocol implements AgentProtocol {
const finalProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this._agentName,
state: SubagentState.COMPLETED,
state: 'completed',
result: finalOutput,
recentActivity: reassembler.toActivityItems(),
};
@@ -37,7 +37,6 @@ describe('SkillExtractionAgent', () => {
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(agent.memoryInboxAccess).toBe(true);
expect(agent.autoMemoryExtractionWriteAccess).toBe(true);
expect(agent.includeExtensionContext).toBe(false);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
@@ -415,7 +415,6 @@ export const SkillExtractionAgent = (
},
memoryInboxAccess: true,
autoMemoryExtractionWriteAccess: true,
includeExtensionContext: false,
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
+2 -15
View File
@@ -88,13 +88,6 @@ export interface SubagentActivityEvent {
data: Record<string, unknown>;
}
export enum SubagentState {
RUNNING = 'running',
COMPLETED = 'completed',
ERROR = 'error',
CANCELLED = 'cancelled',
}
export interface SubagentActivityItem {
id: string;
type: 'thought' | 'tool_call';
@@ -102,14 +95,14 @@ export interface SubagentActivityItem {
displayName?: string;
description?: string;
args?: string;
status: SubagentState;
status: 'running' | 'completed' | 'error' | 'cancelled';
}
export interface SubagentProgress {
isSubagentProgress: true;
agentName: string;
recentActivity: SubagentActivityItem[];
state?: SubagentState;
state?: 'running' | 'completed' | 'error' | 'cancelled';
result?: string;
terminateReason?: AgentTerminateMode;
}
@@ -251,12 +244,6 @@ export interface LocalAgentDefinition<
*/
autoMemoryExtractionWriteAccess?: boolean;
/**
* Controls whether extension memory is injected into this agent's initial
* session context when JIT context is enabled. Defaults to true.
*/
includeExtensionContext?: boolean;
/**
* Optional inline MCP servers for this agent.
*/
@@ -168,46 +168,4 @@ describe('ModelAvailabilityService', () => {
reason: 'quota',
});
});
describe('prefix normalization', () => {
it('treats prefixed and non-prefixed models as identical when marking terminal', () => {
service.markTerminal('models/gemini-3.1-pro-preview', 'quota');
// Checking the non-prefixed version should show it as unavailable
expect(service.snapshot('gemini-3.1-pro-preview')).toEqual({
available: false,
reason: 'quota',
});
// Checking the prefixed version should also show it as unavailable
expect(service.snapshot('models/gemini-3.1-pro-preview')).toEqual({
available: false,
reason: 'quota',
});
});
it('treats prefixed and non-prefixed models as identical when selecting', () => {
service.markTerminal('gemini-3-flash-preview', 'quota');
// Attempting to select the prefixed version should skip it because the base is exhausted
const result = service.selectFirstAvailable([
'models/gemini-3-flash-preview',
'gemini-3.1-pro-preview',
]);
expect(result.selectedModel).toBe('gemini-3.1-pro-preview');
expect(result.skipped).toEqual([
{ model: 'gemini-3-flash-preview', reason: 'quota' },
]);
});
it('treats prefixed and non-prefixed models as identical when marking healthy', () => {
service.markTerminal('gemini-3-flash-preview', 'quota');
service.markHealthy('models/gemini-3-flash-preview');
expect(service.snapshot('gemini-3-flash-preview')).toEqual({
available: true,
});
});
});
});
@@ -39,26 +39,21 @@ export interface ModelSelectionResult {
}>;
}
import { normalizeModelId } from '../utils/modelUtils.js';
export class ModelAvailabilityService {
private readonly health = new Map<ModelId, HealthState>();
markTerminal(modelId: ModelId, reason: TerminalUnavailabilityReason) {
const model = normalizeModelId(modelId);
markTerminal(model: ModelId, reason: TerminalUnavailabilityReason) {
this.setState(model, {
status: 'terminal',
reason,
});
}
markHealthy(modelId: ModelId) {
const model = normalizeModelId(modelId);
markHealthy(model: ModelId) {
this.clearState(model);
}
markRetryOncePerTurn(modelId: ModelId, attempts: number = 1) {
const model = normalizeModelId(modelId);
markRetryOncePerTurn(model: ModelId, attempts: number = 1) {
const currentState = this.health.get(model);
// Do not override a terminal failure with a transient one.
if (currentState?.status === 'terminal') {
@@ -80,16 +75,14 @@ export class ModelAvailabilityService {
});
}
consumeStickyAttempt(modelId: ModelId) {
const model = normalizeModelId(modelId);
consumeStickyAttempt(model: ModelId) {
const state = this.health.get(model);
if (state?.status === 'sticky_retry') {
this.setState(model, { ...state, consumed: true });
}
}
snapshot(modelId: ModelId): ModelAvailabilitySnapshot {
const model = normalizeModelId(modelId);
snapshot(model: ModelId): ModelAvailabilitySnapshot {
const state = this.health.get(model);
if (!state) {
@@ -107,11 +100,10 @@ export class ModelAvailabilityService {
return { available: true };
}
selectFirstAvailable(modelIds: ModelId[]): ModelSelectionResult {
selectFirstAvailable(models: ModelId[]): ModelSelectionResult {
const skipped: ModelSelectionResult['skipped'] = [];
for (const modelId of modelIds) {
const model = normalizeModelId(modelId);
for (const model of models) {
const snapshot = this.snapshot(model);
if (snapshot.available) {
const state = this.health.get(model);
@@ -96,11 +96,10 @@ describe('policyHelpers', () => {
it('starts chain from preferredModel when model is "auto"', () => {
const config = createMockConfig({
getModel: () => 'auto',
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
});
const chain = resolvePolicyChain(config, 'gemini-2.5-flash');
// Due to Gemini 2.x wrapsAround, the chain will contain both flash and pro
expect(chain.length).toBeGreaterThanOrEqual(1);
expect(chain).toHaveLength(1);
expect(chain[0]?.model).toBe('gemini-2.5-flash');
});
+19 -38
View File
@@ -28,7 +28,6 @@ import {
isGemini3Model,
resolveModel,
} from '../config/models.js';
import { normalizeModelId } from '../utils/modelUtils.js';
import type { ModelSelectionResult } from './modelAvailabilityService.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { ApprovalMode } from '../policy/types.js';
@@ -42,13 +41,9 @@ export function resolvePolicyChain(
preferredModel?: string,
wrapsAround: boolean = false,
): ModelPolicyChain {
const normalizedPreferredModel = preferredModel
? normalizeModelId(preferredModel)
: undefined;
const modelFromConfig = normalizeModelId(
normalizedPreferredModel ?? config.getActiveModel?.() ?? config.getModel(),
);
const configuredModel = normalizeModelId(config.getModel());
const modelFromConfig =
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
const configuredModel = config.getModel();
let chain: ModelPolicyChain | undefined;
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
@@ -57,29 +52,19 @@ export function resolvePolicyChain(
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
const resolvedModel = normalizeModelId(
resolveModel(
modelFromConfig,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
),
const resolvedModel = resolveModel(
modelFromConfig,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
);
const isAutoPreferred = normalizedPreferredModel
? isAutoModel(normalizedPreferredModel, config)
const isAutoPreferred = preferredModel
? isAutoModel(preferredModel, config)
: false;
const isAutoConfigured = isAutoModel(configuredModel, config);
// We always wrap around for Gemini 3 chains to ensure maximum availability
// between models in the same family (e.g. fallback to Pro if Flash is exhausted).
const effectiveWrapsAround =
wrapsAround ||
isAutoPreferred ||
isAutoConfigured ||
isGemini3Model(resolvedModel, config);
// --- DYNAMIC PATH ---
if (config.getExperimentalDynamicModelConfiguration?.() === true) {
const context = {
@@ -91,7 +76,7 @@ export function resolvePolicyChain(
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = config.modelConfigService.resolveChain('lite', context);
} else if (
isGemini3Model(normalizeModelId(resolvedModel), config) ||
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
@@ -111,7 +96,7 @@ export function resolvePolicyChain(
const previewEnabled =
hasAccessToPreview &&
(isGemini3Model(resolvedModel, config) ||
normalizedPreferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO);
const autoPrefix = isAutoSelection ? 'auto-' : '';
const chainKey = previewEnabled ? 'preview' : 'default';
@@ -125,7 +110,7 @@ export function resolvePolicyChain(
// No matching modelChains found, default to single model chain
chain = createSingleModelChain(modelFromConfig);
}
chain = applyDynamicSlicing(chain, resolvedModel, effectiveWrapsAround);
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
} else {
// --- LEGACY PATH ---
@@ -140,7 +125,7 @@ export function resolvePolicyChain(
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
normalizedPreferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
@@ -165,7 +150,7 @@ export function resolvePolicyChain(
} else {
chain = createSingleModelChain(modelFromConfig);
}
chain = applyDynamicSlicing(chain, resolvedModel, effectiveWrapsAround);
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
}
// Apply Unified Silent Injection for Plan Mode with defensive checks
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
@@ -186,9 +171,8 @@ function applyDynamicSlicing(
resolvedModel: string,
wrapsAround: boolean,
): ModelPolicyChain {
const normalizedResolved = normalizeModelId(resolvedModel);
const activeIndex = chain.findIndex(
(policy) => normalizeModelId(policy.model) === normalizedResolved,
(policy) => policy.model === resolvedModel,
);
if (activeIndex !== -1) {
return wrapsAround
@@ -216,10 +200,7 @@ export function buildFallbackPolicyContext(
failedPolicy?: ModelPolicy;
candidates: ModelPolicy[];
} {
const normalizedFailed = normalizeModelId(failedModel);
const index = chain.findIndex(
(policy) => normalizeModelId(policy.model) === normalizedFailed,
);
const index = chain.findIndex((policy) => policy.model === failedModel);
if (index === -1) {
return { failedPolicy: undefined, candidates: chain };
}
-4
View File
@@ -60,10 +60,6 @@ async function triggerPostAuthCallbacks(tokens: Credentials) {
refresh_token: tokens.refresh_token ?? undefined, // Ensure null is not passed
type: 'authorized_user',
client_email: userAccountManager.getCachedGoogleAccount() ?? undefined,
quota_project_id:
process.env['GOOGLE_CLOUD_QUOTA_PROJECT'] ||
process.env['GOOGLE_CLOUD_PROJECT'] ||
process.env['GOOGLE_CLOUD_PROJECT_ID'],
};
// Execute all registered post-authentication callbacks.
+81 -52
View File
@@ -100,6 +100,9 @@ vi.mock('../tools/tool-registry', () => {
ToolRegistryMock.prototype.getTool = vi.fn();
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
ToolRegistryMock.prototype.getToolLimitReport = vi
.fn()
.mockReturnValue({ totalActive: 0, allowedCount: 0, ignoredTools: [] });
return { ToolRegistry: ToolRegistryMock };
});
@@ -3525,16 +3528,6 @@ describe('Config JIT Initialization', () => {
expect(sessionMemory).toContain('</project_context>');
expect(sessionMemory).toContain('</loaded_context>');
const sessionMemoryWithoutExtension = config.getSessionMemory({
includeExtensionContext: false,
});
expect(sessionMemoryWithoutExtension).toContain('<loaded_context>');
expect(sessionMemoryWithoutExtension).not.toContain('<extension_context>');
expect(sessionMemoryWithoutExtension).not.toContain('Extension Memory');
expect(sessionMemoryWithoutExtension).toContain('<project_context>');
expect(sessionMemoryWithoutExtension).toContain('Environment Memory');
expect(sessionMemoryWithoutExtension).toContain('</loaded_context>');
// Verify state update (delegated to MemoryContextManager)
expect(config.getGeminiMdFileCount()).toBe(1);
expect(config.getGeminiMdFilePaths()).toEqual(['/path/to/GEMINI.md']);
@@ -3756,8 +3749,6 @@ describe('Config JIT Initialization', () => {
expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
// Writes (the default checkType for isPathAllowed) remain restricted
// to the canonical extraction.patch filenames.
expect(
config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
).toBe(false);
@@ -3766,49 +3757,9 @@ describe('Config JIT Initialization', () => {
path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
),
).toBe(false);
// Reads are broadened to the .inbox/{private,global}/ subtree so the
// extractor can list and inspect prior patches before consolidating.
const privateOtherPatch = path.join(
inboxRoot,
'private',
'other.patch',
);
const globalLeftover = path.join(inboxRoot, 'global', 'topic-a.patch');
const nestedReadPath = path.join(
inboxRoot,
'private',
'nested',
'extraction.patch',
);
expect(config.validatePathAccess(privateOtherPatch, 'read')).toBeNull();
expect(config.validatePathAccess(globalLeftover, 'read')).toBeNull();
expect(config.validatePathAccess(nestedReadPath, 'read')).toBeNull();
expect(config.validatePathAccess(inboxRoot, 'read')).toBeNull();
expect(
config.validatePathAccess(path.join(inboxRoot, 'private'), 'read'),
).toBeNull();
expect(
config.validatePathAccess(path.join(inboxRoot, 'global'), 'read'),
).toBeNull();
// Writes to the same broadened paths are still rejected.
expect(config.validatePathAccess(privateOtherPatch)).toContain(
'Path not in workspace',
);
expect(config.validatePathAccess(nestedReadPath)).toContain(
'Path not in workspace',
);
});
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
// Outside the scope, reads of inbox files are denied again.
expect(
config.validatePathAccess(
path.join(inboxRoot, 'private', 'other.patch'),
'read',
),
).toContain('Path not in workspace');
});
it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
@@ -4331,3 +4282,81 @@ describe('ADKSettings', () => {
expect(config.getAgentSessionNoninteractiveEnabled()).toBe(true);
});
});
describe('Config Tool Limit Warning', () => {
const localParams: ConfigParameters = {
sessionId: 'test-session-id',
targetDir: '/test/dir',
debugMode: false,
model: 'test-model',
cwd: '/tmp',
};
it('should emit warning feedback when tools are ignored', () => {
const config = new Config(localParams);
const mockReport = {
totalActive: 520,
allowedCount: 512,
ignoredTools: ['mcp_server_tool-1', 'mcp_server_tool-2'],
};
const mockToolRegistry = {
getToolLimitReport: vi.fn().mockReturnValue(mockReport),
};
(config as unknown as { _toolRegistry: unknown })._toolRegistry =
mockToolRegistry;
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
config.checkAndWarnToolLimit();
expect(emitFeedbackSpy).toHaveBeenCalledOnce();
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Tool limit exceeded'),
);
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining('mcp_server_tool-1'),
);
});
it('should deduplicate/suppress warnings if the ignored count has not changed', () => {
const config = new Config(localParams);
const mockReport = {
totalActive: 520,
allowedCount: 512,
ignoredTools: ['mcp_server_tool-1', 'mcp_server_tool-2'],
};
const mockToolRegistry = {
getToolLimitReport: vi.fn().mockReturnValue(mockReport),
};
(config as unknown as { _toolRegistry: unknown })._toolRegistry =
mockToolRegistry;
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
config.checkAndWarnToolLimit();
expect(emitFeedbackSpy).toHaveBeenCalledTimes(1);
config.checkAndWarnToolLimit();
expect(emitFeedbackSpy).toHaveBeenCalledTimes(1);
const newMockReport = {
totalActive: 521,
allowedCount: 512,
ignoredTools: [
'mcp_server_tool-1',
'mcp_server_tool-2',
'mcp_server_tool-3',
],
};
mockToolRegistry.getToolLimitReport.mockReturnValue(newMockReport);
config.checkAndWarnToolLimit();
expect(emitFeedbackSpy).toHaveBeenCalledTimes(2);
});
});
+28 -67
View File
@@ -898,6 +898,7 @@ export class Config implements McpContext, AgentLoopContext {
private initialized = false;
private initPromise: Promise<void> | undefined;
private mcpInitializationPromise: Promise<void> | null = null;
private lastIgnoredToolsCount = 0;
readonly storage: Storage;
private readonly fileExclusions: FileExclusions;
private readonly eventEmitter?: EventEmitter;
@@ -1510,6 +1511,7 @@ export class Config implements McpContext, AgentLoopContext {
debugLogger.error('Error initializing MCP clients:', result.reason);
}
}
this.checkAndWarnToolLimit();
});
if (!this.interactive || this.acpMode) {
@@ -2463,6 +2465,25 @@ export class Config implements McpContext, AgentLoopContext {
return this.userMemory;
}
/**
* Checks if any tools were ignored due to the 512 limit and emits a warning feedback event if changed.
*/
checkAndWarnToolLimit(): void {
if (!this._toolRegistry) {
return;
}
const report = this._toolRegistry.getToolLimitReport();
if (report.ignoredTools.length > 0) {
if (report.ignoredTools.length !== this.lastIgnoredToolsCount) {
this.lastIgnoredToolsCount = report.ignoredTools.length;
const message = `⚠️ Tool limit exceeded: Maximum supported tool count is 512. The first 512 tools have been registered (built-in tools prioritized, then discovered and MCP tools). The following ${report.ignoredTools.length} tool(s) are being ignored to prevent API errors: ${report.ignoredTools.join(', ')}.`;
coreEvents.emitFeedback('warning', message);
}
} else {
this.lastIgnoredToolsCount = 0;
}
}
/**
* Refreshes the MCP context, including memory, tools, and system instructions.
*/
@@ -2479,6 +2500,7 @@ export class Config implements McpContext, AgentLoopContext {
await this._geminiClient.setTools();
this._geminiClient.updateSystemInstruction();
}
this.checkAndWarnToolLimit();
}
setUserMemory(newUserMemory: string | HierarchicalMemory): void {
@@ -2511,15 +2533,12 @@ export class Config implements McpContext, AgentLoopContext {
* user message when JIT is enabled. Returns empty string when JIT is
* disabled (Tier 2 memory is already in the system instruction).
*/
getSessionMemory(options?: { includeExtensionContext?: boolean }): string {
getSessionMemory(): string {
if (!this.experimentalJitContext || !this.memoryContextManager) {
return '';
}
const sections: string[] = [];
const includeExtensionContext = options?.includeExtensionContext ?? true;
const extension = includeExtensionContext
? this.memoryContextManager.getExtensionMemory()
: '';
const extension = this.memoryContextManager.getExtensionMemory();
const project = this.memoryContextManager.getEnvironmentMemory();
if (extension?.trim()) {
sections.push(
@@ -3091,49 +3110,12 @@ export class Config implements McpContext, AgentLoopContext {
absolutePath: string,
resolvedPath: string,
inboxRoot: string,
checkType: 'read' | 'write' = 'write',
): boolean {
if (!hasScopedMemoryInboxAccess()) {
return false;
}
const normalizedPath = path.resolve(absolutePath);
const resolvedMemoryRoot = resolveToRealPath(
this.storage.getProjectMemoryTempDir(),
);
// Reads: allow the inbox root and the per-kind subtrees so the extraction
// agent can list/inspect prior patches (including non-canonical filenames
// left over from older runs) before deciding how to rewrite the canonical
// extraction.patch. Writes still flow through the strict canonical-path
// check below so the inbox cannot be backdoored with arbitrary files.
if (checkType === 'read') {
const resolvedInboxRoot = resolveToRealPath(inboxRoot);
const normalizedInboxRoot = path.resolve(inboxRoot);
if (
resolvedPath === resolvedInboxRoot ||
normalizedPath === normalizedInboxRoot
) {
return isSubpath(resolvedMemoryRoot, resolvedPath);
}
for (const kind of ['private', 'global'] as const) {
const kindRoot = path.join(inboxRoot, kind);
const resolvedKindRoot = resolveToRealPath(kindRoot);
const normalizedKindRoot = path.resolve(kindRoot);
if (
resolvedPath === resolvedKindRoot ||
normalizedPath === normalizedKindRoot ||
isSubpath(resolvedKindRoot, resolvedPath) ||
isSubpath(normalizedKindRoot, normalizedPath)
) {
return isSubpath(resolvedMemoryRoot, resolvedPath);
}
}
return false;
}
const isCanonicalPatchPath = (['private', 'global'] as const).some(
(kind) =>
normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
@@ -3142,6 +3124,9 @@ export class Config implements McpContext, AgentLoopContext {
return false;
}
const resolvedMemoryRoot = resolveToRealPath(
this.storage.getProjectMemoryTempDir(),
);
return isSubpath(resolvedMemoryRoot, resolvedPath);
}
@@ -3185,9 +3170,7 @@ export class Config implements McpContext, AgentLoopContext {
* the auto-memory extraction agent and the `/memory inbox` review flow. The
* main agent is denied access to it even though it falls inside the project
* temp dir; the extraction agent receives a narrow execution-scoped exception
* for *writes* to `.inbox/{private,global}/extraction.patch`. Scoped *read*
* access to the wider `.inbox/{private,global}/` subtree is granted in
* `validatePathAccess` so the extractor can enumerate prior patches.
* for `.inbox/{private,global}/extraction.patch`.
*
* @param absolutePath The absolute path to check.
* @returns true if the path is allowed, false otherwise.
@@ -3282,28 +3265,6 @@ export class Config implements McpContext, AgentLoopContext {
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
return null;
}
// The memory inbox is carved out of the standard temp-dir allowlist by
// `isPathAllowed`. The extraction agent is granted a scoped read
// exception so it can enumerate prior patches (including non-canonical
// filenames) before consolidating them into the canonical
// extraction.patch. Writes remain restricted to canonical paths.
if (hasScopedMemoryInboxAccess()) {
const inboxRoot = path.join(
this.storage.getProjectMemoryTempDir(),
'.inbox',
);
if (
this.isScopedMemoryInboxPatchPathAllowed(
absolutePath,
resolveToRealPath(absolutePath),
inboxRoot,
'read',
)
) {
return null;
}
}
}
// Then check standard allowed paths (Workspace + Temp)
+1 -1
View File
@@ -345,7 +345,7 @@ export function isGemini3Model(
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior resolves the model first.
const resolved = resolveModel(model, false, false, false, true, config);
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.family ===
'gemini-3'
@@ -374,65 +374,4 @@ describe('ProjectRegistry', () => {
readFileSpy.mockRestore();
});
it('recovers gracefully if registry is an empty object (invalid schema)', async () => {
// 1. Write an empty object which is valid JSON but invalid schema
fs.writeFileSync(registryPath, '{}');
const registry = new ProjectRegistry(registryPath);
await registry.initialize();
// 2. It should not crash and should allow adding new projects
const projectPath = path.join(tempDir, 'my-project');
const shortId = await registry.getShortId(projectPath);
expect(shortId).toBe('my-project');
// 3. Verify it healed the file
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects).toBeDefined();
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
});
it('recovers gracefully if registry projects property is an array (invalid schema)', async () => {
// 1. Write an object where 'projects' is an array
fs.writeFileSync(registryPath, JSON.stringify({ projects: [] }));
const registry = new ProjectRegistry(registryPath);
await registry.initialize();
// 2. It should reset and allow adding new projects correctly
const projectPath = path.join(tempDir, 'my-project');
const shortId = await registry.getShortId(projectPath);
expect(shortId).toBe('my-project');
// 3. Verify it healed the file to an object
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects).toBeDefined();
expect(Array.isArray(data.projects)).toBe(false);
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
});
it('recovers gracefully if registry contains malicious slugs (path traversal)', async () => {
// 1. Write a registry with a path traversal slug
fs.writeFileSync(
registryPath,
JSON.stringify({ projects: { '/some/path': '../../etc/passwd' } }),
);
const registry = new ProjectRegistry(registryPath);
await registry.initialize();
// 2. It should identify as invalid and reset
const projectPath = path.join(tempDir, 'my-project');
const shortId = await registry.getShortId(projectPath);
expect(shortId).toBe('my-project');
// 3. Verify it healed the file and didn't preserve the malicious entry
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
expect(Object.values(data.projects)).not.toContain('../../etc/passwd');
});
});
+2 -19
View File
@@ -9,7 +9,6 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { lock } from 'proper-lockfile';
import { z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError } from '../utils/errors.js';
@@ -17,10 +16,6 @@ export interface RegistryData {
projects: Record<string, string>;
}
const registryDataSchema = z.object({
projects: z.record(z.string(), z.string().regex(/^[a-z0-9-]+$/)),
});
const PROJECT_ROOT_FILE = '.project_root';
const LOCK_TIMEOUT_MS = 10000;
const LOCK_RETRY_DELAY_MS = 100;
@@ -62,16 +57,8 @@ export class ProjectRegistry {
private async loadData(): Promise<RegistryData> {
try {
const content = await fs.promises.readFile(this.registryPath, 'utf8');
const parsed: unknown = JSON.parse(content);
if (this.isValidRegistryData(parsed)) {
return parsed;
}
debugLogger.warn(
`Project registry at ${this.registryPath} has an invalid schema, resetting to empty.`,
);
return { projects: {} };
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(content);
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
return { projects: {} }; // Normal first run
@@ -420,8 +407,4 @@ export class ProjectRegistry {
.replace(/^-|-$/g, '') || 'project'
);
}
private isValidRegistryData(data: unknown): data is RegistryData {
return registryDataSchema.safeParse(data).success;
}
}
@@ -37,8 +37,8 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
]);
// 3. Add massive history that blows past the 150k maxTokens limit
// 20 turns * ~20,000 tokens/turn (10k user + 10k model) = ~400,000 tokens
const massiveHistory = createSyntheticHistory(20, 10000);
// 20 turns * 10,000 tokens/turn = ~200,000 tokens
const massiveHistory = createSyntheticHistory(20, 35000);
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
// 4. Add the Latest Turn (Protected)
@@ -60,8 +60,8 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
expect(projection[0].role).toBe('user');
const projectionString = JSON.stringify(projection);
expect(projectionString).toContain('User turn 17');
expect(projection[0].parts![0].text).toContain('User turn 17');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
(p) =>
@@ -1,92 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
createMockContextConfig,
setupContextComponentTest,
createMockLlmClient,
} from './testing/contextTestUtils.js';
import { stressTestProfile } from './config/profiles.js';
describe('ContextManager - Hot Start Calibration', () => {
it('should not perform calibration if the buffer is empty', async () => {
const mockLlm = createMockLlmClient();
const config = createMockContextConfig(undefined, mockLlm);
const { contextManager } = setupContextComponentTest(
config,
stressTestProfile,
);
// We can spy on the underlying mock LLM client countTokens
const countTokensSpy = vi.spyOn(mockLlm, 'countTokens');
// Render an empty graph
await contextManager.renderHistory();
expect(countTokensSpy).not.toHaveBeenCalled();
});
it('should perform calibration exactly once when rendering with existing nodes', async () => {
const mockLlm = createMockLlmClient();
const countTokensSpy = vi
.spyOn(mockLlm, 'countTokens')
.mockResolvedValue({ totalTokens: 42 });
const config = createMockContextConfig(undefined, mockLlm);
const { contextManager, chatHistory } = setupContextComponentTest(
config,
stressTestProfile,
);
// We need to access the env's eventBus inside the contextManager
const env = Reflect.get(contextManager, 'env');
const emitGroundTruthSpy = vi.spyOn(env.eventBus, 'emitTokenGroundTruth');
// Add a node to make the buffer non-empty
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
// First render should trigger calibration
await contextManager.renderHistory();
expect(countTokensSpy).toHaveBeenCalledTimes(1);
expect(emitGroundTruthSpy).toHaveBeenCalledTimes(1);
expect(emitGroundTruthSpy).toHaveBeenCalledWith(
expect.objectContaining({
actualTokens: 42,
promptBaseUnits: 10,
}),
);
// Second render should skip calibration
await contextManager.renderHistory();
expect(countTokensSpy).toHaveBeenCalledTimes(1);
// emit hasn't been called again
expect(emitGroundTruthSpy).toHaveBeenCalledTimes(1);
});
it('should silently swallow errors if countTokens API fails', async () => {
const mockLlm = createMockLlmClient();
const countTokensSpy = vi
.spyOn(mockLlm, 'countTokens')
.mockRejectedValue(new Error('API failure'));
const config = createMockContextConfig(undefined, mockLlm);
const { contextManager, chatHistory } = setupContextComponentTest(
config,
stressTestProfile,
);
// Add a node
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
// Render should succeed without throwing
const result = await contextManager.renderHistory();
expect(result.history).toBeDefined();
expect(countTokensSpy).toHaveBeenCalledTimes(1);
});
});
+15 -99
View File
@@ -18,7 +18,6 @@ import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
import { debugLogger } from '../utils/debugLogger.js';
import { hardenHistory } from '../utils/historyHardening.js';
import { checkContextInvariants } from './utils/invariantChecker.js';
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
export class ContextManager {
// The master state containing the pristine graph and current active graph.
@@ -37,22 +36,15 @@ export class ContextManager {
// Cache for Anomaly 3 (Redundant Renders)
private lastRenderCache?: {
nodesHash: string;
result: {
history: Content[];
didApplyManagement: boolean;
baseUnits: number;
};
result: { history: Content[]; didApplyManagement: boolean };
};
private hasPerformedHotStart = false;
constructor(
private readonly sidecar: ContextProfile,
private readonly env: ContextEnvironment,
private readonly tracer: ContextTracer,
orchestrator: PipelineOrchestrator,
chatHistory: AgentChatHistory,
private readonly advancedTokenCalculator: AdvancedTokenCalculator,
private readonly headerProvider?: () => Promise<Content | undefined>,
) {
this.eventBus = env.eventBus;
@@ -268,10 +260,6 @@ export class ContextManager {
return [...this.buffer.nodes];
}
getEnvironment(): ContextEnvironment {
return this.env;
}
/**
* Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget,
* and maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
@@ -280,44 +268,22 @@ export class ContextManager {
async renderHistory(
pendingRequest?: Content,
activeTaskIds: Set<string> = new Set(),
abortSignal?: AbortSignal,
): Promise<{
history: Content[];
didApplyManagement: boolean;
baseUnits: number;
}> {
): Promise<{ history: Content[]; didApplyManagement: boolean }> {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
let previewNodes: ConcreteNode[] = [];
if (pendingRequest) {
previewNodes = this.env.graphMapper.applyEvent({
type: 'PUSH',
payload: [pendingRequest],
});
}
// --- Hot Start Calibration ---
// If we are resuming a session with history, we don't want the adaptive token calculator
// to fly blind on its first GC pass. We do a one-time API calibration.
const hotStartPromise = (async () => {
if (!this.hasPerformedHotStart) {
this.hasPerformedHotStart = true;
if (this.buffer.nodes.length > 0) {
const nodesForHotStart = [...this.buffer.nodes, ...previewNodes];
await this.performHotStartCalibration(nodesForHotStart, abortSignal);
}
}
})();
// 1. Synchronous Pressure Barrier: Wait for background management pipelines to finish.
// We run hot start calibration in parallel to hide the network latency.
await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]);
// This ensures that the render sees the results of recent pushes (Anomaly 2).
await this.orchestrator.waitForPipelines();
let nodes = this.buffer.nodes;
const previewNodeIds = new Set<string>();
// Apply the preview nodes to the final graph
if (previewNodes.length > 0) {
// If we have a pending request, we need to build a 'preview' graph for this render.
if (pendingRequest) {
const previewNodes = this.env.graphMapper.applyEvent({
type: 'PUSH',
payload: [pendingRequest],
});
for (const n of previewNodes) {
previewNodeIds.add(n.id);
}
@@ -328,6 +294,9 @@ export class ContextManager {
const header = this.headerProvider
? await this.headerProvider()
: undefined;
const headerTokens = header
? this.env.tokenCalculator.calculateContentTokens(header)
: 0;
// 3. Cache Check (Anomaly 3): If nodes haven't changed, return previous result.
// We combine the graph hash with a hash of the header to ensure total freshness.
@@ -345,19 +314,14 @@ export class ContextManager {
const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
// Apply final GC Backstop pressure barrier synchronously before mapping
const {
history: renderedHistory,
didApplyManagement,
baseUnits,
} = await render(
const { history: renderedHistory, didApplyManagement } = await render(
nodes,
this.orchestrator,
this.sidecar,
this.tracer,
this.env,
this.advancedTokenCalculator,
protectionReasons,
header,
headerTokens,
previewNodeIds,
);
@@ -375,58 +339,10 @@ export class ContextManager {
sentinels: this.sidecar.sentinels,
}),
didApplyManagement,
baseUnits,
};
// Update cache
this.lastRenderCache = { nodesHash: totalHash, result };
return result;
}
private async performHotStartCalibration(
nodes: readonly ConcreteNode[],
abortSignal?: AbortSignal,
) {
try {
this.tracer.logEvent(
'ContextManager',
'Performing Hot Start Token Calibration',
);
const contents = this.env.graphMapper.fromGraph(nodes);
const header = this.headerProvider
? await this.headerProvider()
: undefined;
const combinedHistory = header ? [header, ...contents] : contents;
const baseUnits =
this.advancedTokenCalculator.getRawBaseUnits(nodes) +
(header
? this.advancedTokenCalculator.getRawBaseUnitsForContent(header)
: 0);
// We only make the network call if we have actual contents to send,
// avoiding 400 Bad Request errors from the API.
if (combinedHistory.length > 0) {
const result = await this.env.llmClient.countTokens({
contents: combinedHistory,
abortSignal,
});
if (result.totalTokens > 0) {
this.env.eventBus.emitTokenGroundTruth({
actualTokens: result.totalTokens,
promptBaseUnits: baseUnits,
});
}
}
} catch (error) {
// Hot start calibration is purely an optimization. If the network fails or auth is weird,
// we silently swallow and fallback to the un-calibrated 1.0 ratio heuristic.
this.tracer.logEvent(
'ContextManager',
'Hot Start Token Calibration Failed (Ignored)',
{ error },
);
}
}
}
-13
View File
@@ -29,20 +29,7 @@ export interface ChunkReceivedEvent {
targetNodeIds: Set<string>;
}
export interface TokenGroundTruthEvent {
actualTokens: number;
promptBaseUnits: number;
}
export class ContextEventBus extends EventEmitter {
emitTokenGroundTruth(event: TokenGroundTruthEvent) {
this.emit('TOKEN_GROUND_TRUTH', event);
}
onTokenGroundTruth(listener: (event: TokenGroundTruthEvent) => void) {
this.on('TOKEN_GROUND_TRUTH', listener);
}
emitPristineHistoryUpdated(event: PristineHistoryUpdatedEvent) {
this.emit('PRISTINE_HISTORY_UPDATED', event);
}
+5 -49
View File
@@ -8,7 +8,6 @@ import { describe, it, expect, vi } from 'vitest';
import { render } from './render.js';
import type { ConcreteNode } from './types.js';
import { NodeType } from './types.js';
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import type { ContextTracer } from '../tracer.js';
import type { ContextProfile } from '../config/profiles.js';
@@ -38,20 +37,7 @@ describe('render', () => {
const orchestrator = {} as PipelineOrchestrator;
const sidecar = { config: {} } as ContextProfile; // No budget
const mockAdvancedTokenCalculator = {
calculateTokensAndBaseUnits: vi.fn().mockReturnValue({
tokens: 100,
baseUnits: 100,
}),
getRawBaseUnits: vi.fn().mockReturnValue(100),
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
};
const env = {
tokenCalculator: {
calculateConcreteListTokens: vi.fn().mockReturnValue(100),
calculateTokenBreakdown: vi.fn().mockReturnValue({}),
},
graphMapper: {
fromGraph: vi.fn((nodes: readonly ConcreteNode[]) =>
nodes.map((n) => ({ text: n.id })),
@@ -68,14 +54,12 @@ describe('render', () => {
sidecar,
tracer,
env,
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
new Map(),
undefined,
0,
previewNodeIds,
);
expect(result.history).toEqual([{ text: '1' }, { text: '2' }]);
expect(result.baseUnits).toBe(100);
});
it('simulates the boundary knapsack problem (loose boundary policy)', async () => {
@@ -124,24 +108,12 @@ describe('render', () => {
const currentTokens = 160000;
const mockAdvancedTokenCalculator = {
calculateTokensAndBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
const tokens =
nodes.length === 1 ? tokenMap[nodes[0].id] : currentTokens;
return { tokens, baseUnits: tokens };
}),
getRawBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
if (nodes.length === 1) return tokenMap[nodes[0].id];
return currentTokens;
}),
};
const env = {
llmClient: {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
},
tokenCalculator: {
calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => {
calculateConcreteListTokens: vi.fn((nodes) => {
if (nodes.length === 1) return tokenMap[nodes[0].id];
return currentTokens;
}),
@@ -164,9 +136,8 @@ describe('render', () => {
sidecar,
tracer,
env,
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
new Map(),
undefined,
0,
new Set(),
);
@@ -176,7 +147,6 @@ describe('render', () => {
// Adding C pushes rolling total (70k) above retainedTokens (65k).
// Under loose policy, C survives. D is strictly older and drops.
expect(surviving).toEqual(['C', 'B', 'A']); // D is dropped
expect(result.baseUnits).toBe(160000);
});
it('drops nodes that are STRICTLY older than the boundary node', async () => {
@@ -218,24 +188,12 @@ describe('render', () => {
const currentTokens = 160000;
const mockAdvancedTokenCalculator = {
calculateTokensAndBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
const tokens =
nodes.length === 1 ? tokenMap[nodes[0].id] : currentTokens;
return { tokens, baseUnits: tokens };
}),
getRawBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
if (nodes.length === 1) return tokenMap[nodes[0].id];
return currentTokens;
}),
};
const env = {
llmClient: {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
},
tokenCalculator: {
calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => {
calculateConcreteListTokens: vi.fn((nodes) => {
if (nodes.length === 1) return tokenMap[nodes[0].id];
return currentTokens;
}),
@@ -258,9 +216,8 @@ describe('render', () => {
sidecar,
tracer,
env,
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
new Map(),
undefined,
0,
new Set(),
);
@@ -268,6 +225,5 @@ describe('render', () => {
const surviving = result.history.map((c: any) => c.text);
// C(40k), B(40k). Adding B pushes total to 80k. B is the boundary node and survives. A drops.
expect(surviving).toEqual(['B', 'C']); // A is dropped
expect(result.baseUnits).toBe(160000);
});
});
+6 -38
View File
@@ -11,7 +11,6 @@ import type { ContextProfile } from '../config/profiles.js';
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { performCalibration } from '../utils/tokenCalibration.js';
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
/**
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
@@ -23,43 +22,21 @@ export async function render(
sidecar: ContextProfile,
tracer: ContextTracer,
env: ContextEnvironment,
advancedTokenCalculator: AdvancedTokenCalculator,
protectionReasons: Map<string, string> = new Map(),
header?: Content,
headerTokens: number = 0,
previewNodeIds: ReadonlySet<string> = new Set(),
): Promise<{
history: Content[];
didApplyManagement: boolean;
baseUnits: number;
}> {
let headerTokens = 0;
let headerBaseUnits = 0;
if (header) {
const costs =
advancedTokenCalculator.calculateContentTokensAndBaseUnits(header);
headerTokens = costs.tokens;
headerBaseUnits = costs.baseUnits;
}
): Promise<{ history: Content[]; didApplyManagement: boolean }> {
if (!sidecar.config.budget) {
const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id));
const contents = env.graphMapper.fromGraph(visibleNodes);
tracer.logEvent('Render', 'Render Context to LLM (No Budget)', {
renderedContext: contents,
});
// In all cases, retrieve raw base units from the token calculator interface
const baseUnits =
advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits;
return { history: contents, didApplyManagement: false, baseUnits };
return { history: contents, didApplyManagement: false };
}
const maxTokens = sidecar.config.budget.maxTokens;
const { tokens: graphTokens, baseUnits: graphBaseUnits } =
advancedTokenCalculator.calculateTokensAndBaseUnits(nodes);
const graphTokens = env.tokenCalculator.calculateConcreteListTokens(nodes);
const currentTokens = graphTokens + headerTokens;
const protectedIds = new Set(protectionReasons.keys());
@@ -93,11 +70,7 @@ export async function render(
renderedContext: contents,
});
performCalibration(env, visibleNodes, contents);
return {
history: contents,
didApplyManagement: false,
baseUnits: graphBaseUnits + headerBaseUnits,
};
return { history: contents, didApplyManagement: false };
}
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
tracer.logEvent(
@@ -146,10 +119,5 @@ export async function render(
renderedContextSanitized: contents,
});
performCalibration(env, visibleNodes, contents);
return {
history: contents,
didApplyManagement: true,
baseUnits:
advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits,
};
return { history: contents, didApplyManagement: true };
}

Some files were not shown because too many files have changed in this diff Show More