diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
index 3c1b5de6..95fb5eaa 100644
--- a/.github/workflows/gitleaks.yml
+++ b/.github/workflows/gitleaks.yml
@@ -1,10 +1,8 @@
name: gitleaks
-# Block PRs that introduce hardcoded credentials. Runs the upstream gitleaks
-# action against the diff (PR) or full history (push to main). False
-# positives in the working tree are caught by the gitleaks-action's own
-# allowlist mechanism — extend .gitleaks.toml at repo root rather than
-# editing this workflow.
+# Block PRs that introduce hardcoded credentials. Runs the gitleaks CLI
+# directly (rather than gitleaks-action) because the action requires a
+# paid license on GitHub Orgs. Same scanner, same rules, same .gitleaks.toml.
on:
pull_request:
@@ -14,22 +12,54 @@ on:
permissions:
contents: read
- pull-requests: read
jobs:
scan:
runs-on: ubuntu-latest
+ env:
+ GITLEAKS_VERSION: '8.21.2'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
- # Full history needed so gitleaks can scan all new commits in a PR.
- fetch-depth: 0
+ fetch-depth: 0 # Full history so PR-diff scanning works.
- - name: Run gitleaks
- uses: gitleaks/gitleaks-action@v2
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Treat any high-confidence finding as a hard fail.
- GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'true'
- GITLEAKS_ENABLE_SUMMARY: 'true'
+ - name: Install gitleaks
+ run: |
+ set -euo pipefail
+ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
+ | tar -xz -C /tmp gitleaks
+ sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
+ gitleaks version
+
+ - name: Run gitleaks (PR diff)
+ if: github.event_name == 'pull_request'
+ run: |
+ gitleaks detect \
+ --source . \
+ --redact \
+ --verbose \
+ --no-banner \
+ --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
+
+ - name: Run gitleaks (push)
+ if: github.event_name == 'push'
+ run: |
+ set -euo pipefail
+ BEFORE="${{ github.event.before }}"
+ AFTER="${{ github.sha }}"
+ # New-branch push: GH sends 40 zeros for `before`. Diff against
+ # main's merge-base instead so we only scan commits unique to the
+ # branch — fast and matches the gitleaks-action default.
+ if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
+ git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main 2>/dev/null || true
+ if git rev-parse --verify origin/main >/dev/null 2>&1; then
+ BEFORE=$(git merge-base origin/main "$AFTER" 2>/dev/null || echo "")
+ fi
+ fi
+ if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
+ gitleaks detect --source . --redact --verbose --no-banner --log-opts="${BEFORE}..${AFTER}"
+ else
+ # Couldn't establish a range — full scan as fallback.
+ gitleaks detect --source . --redact --verbose --no-banner
+ fi
diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index a4e13927..fda0e3fd 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -692,55 +692,46 @@ class AgentManager:
if not active_lines and not available_lines:
return None
+ # Static preamble first (kept byte-identical across users so it caches),
+ # then the per-session server list. Worked-example uses generic
+ # placeholders so a Pro Anthropic prompt-cache hit isn't broken by
+ # one user's connector names differing from another's.
sections = [""]
sections.append(
- "MCP servers are gated: the model cannot call any MCP tool until "
- "the user approves an MCPActivate request for that server. To use "
- "a server below, first call MCPSearch (to confirm the right server "
- "for the task), then call MCPActivate(server_name) — the user will "
- "be prompted to approve activation. After approval, the server's "
- "tools (`mcp____`) become callable on the next turn."
+ "MCP servers are gated: their tools are uncallable until the user "
+ "approves an MCPActivate request. To use one below, call MCPSearch "
+ "(if unsure which) then MCPActivate(server_name); after approval the "
+ "server's tools (`mcp____`) become callable next turn."
)
sections.append("")
- sections.append("## CRITICAL behavioral rules (follow these exactly)")
+ sections.append("## Rules")
sections.append(
- "1. If the user's request implies an integration listed below "
- "(email, calendar, slack, notion, etc.) and that server is NOT "
- "in the Active section, your FIRST tool call MUST be MCPSearch "
- "or MCPActivate. Do NOT call any other tool that looks like an "
- "auth/login helper (e.g. `mcp__*__authenticate`, "
- "`mcp__claude_ai_*__authenticate`) — those are legacy shims "
- "and will not work. Always go through MCPActivate."
+ "1. If the user's request needs a server below that isn't Active, "
+ "your FIRST tool call must be MCPSearch or MCPActivate. Ignore any "
+ "`mcp__*__authenticate` helpers — those are legacy shims; always go "
+ "through MCPActivate."
)
sections.append(
- "2. After MCPActivate returns, end your turn cleanly. The "
- "system will automatically run a follow-up turn with the "
- "newly-activated tools available — you do NOT need the user "
- "to re-prompt. Just stop and let the next turn fire."
+ "2. After MCPActivate returns, end the turn — a follow-up turn fires "
+ "automatically with the new tools available."
)
sections.append(
- "3. Do not ask the user 'should I activate X?' before calling "
- "MCPActivate — MCPActivate already triggers an explicit user "
- "approval prompt via the standard tool-approval UI. Asking "
- "again wastes a round-trip."
+ "3. Don't ask 'should I activate X?' first — MCPActivate already "
+ "triggers an approval prompt."
)
sections.append("")
- sections.append("## Worked example")
+ sections.append("## Example")
sections.append(
- "User: \"check my email\"\n"
- "Active MCPs: (none)\n"
- "Available MCPs: google-workspace, microsoft-365\n"
- "→ Your first tool call is `MCPActivate(server_name=\"google-workspace\", reason=\"checking inbox\")`.\n"
- " After it returns, end your turn. Next turn, call "
- "`mcp__google-workspace__query_gmail_emails(...)` to actually "
- "fetch the email."
+ "User asks for email; no email server is Active. First tool call: "
+ "`MCPActivate(server_name=\"\", reason=\"...\")`. End "
+ "turn. Next turn: call the activated server's email tool."
)
sections.append("")
if active_lines:
- sections.append("Active (already approved this session — tools callable now):")
+ sections.append("Active (callable now):")
sections.extend(active_lines)
if available_lines:
- sections.append("\nAvailable (installed but not yet activated):")
+ sections.append("\nAvailable (not yet activated):")
sections.extend(available_lines)
sections.append("")
return "\n".join(sections)
@@ -2045,6 +2036,13 @@ class AgentManager:
try:
level = getattr(session, "thinking_level", "auto") or "auto"
+ # Trivially short prompts ("hi", "thanks") don't benefit from
+ # 5-30s of hidden reasoning. Override per-turn only — session
+ # setting is untouched so the UI pill keeps reflecting the
+ # user's choice.
+ _prompt_len = len((prompt or "").strip())
+ if 0 < _prompt_len < 50 and level != "off":
+ level = "off"
# gc/gemini-3* without Antigravity 400s every multi-step turn
# on thoughtSignature continuity. Force-disable thinking.
if (
diff --git a/backend/apps/agents/mcp_meta_server.py b/backend/apps/agents/mcp_meta_server.py
index bdf9ef09..887388b4 100644
--- a/backend/apps/agents/mcp_meta_server.py
+++ b/backend/apps/agents/mcp_meta_server.py
@@ -35,11 +35,9 @@ TOOLS = [
{
"name": "MCPList",
"description": (
- "List the MCP servers installed on this machine. Returns one entry "
- "per server with its name, one-sentence purpose, and current "
- "activation status (active/available). Costs almost nothing — the "
- "registry is a flat list, no schemas. Use this when you want a "
- "broad survey before picking a server."
+ "List installed MCP servers (name, one-sentence purpose, "
+ "active/available status). Cheap. Use for a broad survey before "
+ "picking a server."
),
"inputSchema": {
"type": "object",
@@ -50,18 +48,16 @@ TOOLS = [
{
"name": "MCPSearch",
"description": (
- "Find MCP servers relevant to a query. Returns the top matches "
- "ranked by description match against the query (e.g. 'email', "
- "'calendar', 'spreadsheet'). Use this BEFORE MCPActivate when you "
- "are not sure which server to enable. The server's tools are NOT "
- "callable yet — you still need MCPActivate after picking one."
+ "Rank MCP servers by relevance to a query. Use before MCPActivate "
+ "when unsure which server fits. Tools are NOT callable until you "
+ "also MCPActivate."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
- "description": "Free-form description of what you need (e.g. 'send email', 'read inbox', 'post to slack').",
+ "description": "What you need (e.g. 'send email', 'post to slack').",
},
},
"required": ["query"],
@@ -71,24 +67,21 @@ TOOLS = [
{
"name": "MCPActivate",
"description": (
- "Request activation of an MCP server for this session. The user is "
- "prompted via the standard tool-approval UI; on approve, the "
- "server's tools become callable on the NEXT turn (the current turn "
- "ends after this call). On deny, the server stays unavailable and "
- "you should ask the user how to proceed. Always call MCPSearch or "
- "MCPList first to confirm the server name — invalid names return "
- "a list of valid alternatives instead of activating."
+ "Request activation of an MCP server for this session. Triggers a "
+ "user approval prompt; on approve the server's tools become callable "
+ "next turn. Always confirm the server name via MCPList/MCPSearch first — "
+ "invalid names return alternatives instead of activating."
),
"inputSchema": {
"type": "object",
"properties": {
"server_name": {
"type": "string",
- "description": "Sanitized server name as returned by MCPList/MCPSearch (e.g. 'gmail', 'slack', 'discord').",
+ "description": "Sanitized name from MCPList/MCPSearch (e.g. 'gmail', 'slack').",
},
"reason": {
"type": "string",
- "description": "One-sentence explanation of why you need this server, shown to the user in the approval prompt to help them decide.",
+ "description": "Why you need it — shown to the user in the approval prompt.",
},
},
"required": ["server_name"],
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index 57d25352..a52e6218 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -494,6 +494,24 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
return out;
}, [modelSearch, allModelOptions.grouped, capFilters, ctxIdx, costIdx]);
+ // Footer summary — counts reflect what the user actually has access to
+ // right now (post-filter, post-credentials).
+ const pickerSummary = useMemo(() => {
+ let total = 0, free = 0, reasoning = 0, subscription = 0, apiKey = 0, paid = 0, longContext = 0;
+ for (const ms of Object.values(filteredModelGroups)) {
+ for (const m of ms as any[]) {
+ total += 1;
+ if (m.reasoning) reasoning += 1;
+ if ((m.context_window ?? 0) >= 1_000_000) longContext += 1;
+ if (m.billing_kind === 'free') free += 1;
+ else if (m.billing_kind === 'subscription') subscription += 1;
+ else if (m.billing_kind === 'api_key') apiKey += 1;
+ else if (m.billing_kind === 'paid') paid += 1;
+ }
+ }
+ return { total, free, reasoning, subscription, apiKey, paid, longContext };
+ }, [filteredModelGroups]);
+
// Recents materialised against current catalog so removed models drop out.
const recentMaterialised = useMemo(() => {
const flatByValue = new Map(allModelOptions.flat.map((m) => [m.value, m]));
@@ -2009,11 +2027,64 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
px: 1.25, py: 0.5,
fontSize: '0.65rem', color: c.text.ghost,
display: 'flex', justifyContent: 'space-between',
- pointerEvents: 'none',
+ gap: 1,
}}
>
- Type to search · Esc to close
- {Object.values(filteredModelGroups).reduce((sum, ms) => sum + (ms as any[]).length, 0)} models
+
+ Type to search · Esc to close
+
+ {(() => {
+ const breakdown: Array<[string, number]> = ([
+ ['Free', pickerSummary.free],
+ ['Subscription', pickerSummary.subscription],
+ ['API key', pickerSummary.apiKey],
+ ['Pay-per-use', pickerSummary.paid],
+ ['Reasoning', pickerSummary.reasoning],
+ ['1M+ context', pickerSummary.longContext],
+ ] as Array<[string, number]>).filter(([, n]) => n > 0);
+ const breakdownTooltip = breakdown.length > 0 ? (
+
+
+ {pickerSummary.total} model{pickerSummary.total === 1 ? '' : 's'} available
+
+
+ {breakdown.map(([label, n]) => (
+
+ {label}
+ {n}
+
+ ))}
+
+
+ ) : null;
+ return (
+
+
+ {pickerSummary.total} model{pickerSummary.total === 1 ? '' : 's'}
+
+
+ );
+ })()}