diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml index ada0b79f23c..b83b0ba3d3f 100644 --- a/.github/workflows/nix-lockfile-fix.yml +++ b/.github/workflows/nix-lockfile-fix.yml @@ -75,9 +75,10 @@ jobs: run: | set -euo pipefail - # Ensure only nix files were modified — prevents accidental - # self-triggering if fix-lockfiles ever touches package files. - unexpected="$(git diff --name-only | grep -Ev '^nix/(tui|web)\.nix$' || true)" + # Ensure only nix/lib.nix (home of the single npmDepsHash) was + # modified — prevents accidental self-triggering if fix-lockfiles + # ever touches package files. + unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)" if [ -n "$unexpected" ]; then echo "::error::Unexpected modified files: $unexpected" exit 1 @@ -89,7 +90,7 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add nix/tui.nix nix/web.nix + git add nix/lib.nix git commit -m "fix(nix): auto-refresh npm lockfile hashes" \ -m "Source: $GITHUB_SHA" \ -m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" @@ -216,7 +217,7 @@ jobs: set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add nix/tui.nix nix/web.nix + git add nix/lib.nix git commit -m "fix(nix): refresh npm lockfile hashes" git push diff --git a/README.md b/README.md index b8fe2117147..a8db8cb2c29 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ License: MIT Built by Nous Research 中文 + اردو

**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM. @@ -52,7 +53,7 @@ If you already have Git installed, the installer detects it and uses that instea > **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. > -> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). +> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. After installation: diff --git a/README.ur-pk.md b/README.ur-pk.md new file mode 100644 index 00000000000..100b7461a02 --- /dev/null +++ b/README.ur-pk.md @@ -0,0 +1,261 @@ +
+ +

+ Hermes Agent +

+ +# ہرمیس ایجنٹ ☤ (Hermes Agent) + +

+ Documentation + Discord + License: MIT + Built by Nous Research + English + 中文 +

+ +**[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔ + +آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [Nous Portal](https://portal.nousresearch.com)، [OpenRouter](https://openrouter.ai) (200 سے زائد ماڈلز)، [NovitaAI](https://novita.ai) (ماڈل API، ایجنٹ سینڈ باکس، اور GPU کلاؤڈ کے لیے اے آئی مقامی کلاؤڈ)، [NVIDIA NIM](https://build.nvidia.com) (Nemotron)، [Xiaomi MiMo](https://platform.xiaomimimo.com)، [z.ai/GLM](https://z.ai)، [Kimi/Moonshot](https://platform.moonshot.ai)، [MiniMax](https://www.minimax.io)، [Hugging Face](https://huggingface.co)، OpenAI، یا اپنا حسب ضرورت اینڈ پوائنٹ (endpoint) استعمال کریں۔ ماڈل تبدیل کرنے کے لیے صرف `hermes model` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔ + + + + + + + + + +
حقیقی ٹرمینل انٹرفیسمکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔
یہ وہاں موجود ہے جہاں آپ ہیںٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔
سیکھنے کا ایک مکمل عملایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ Honcho کے ذریعے صارف کی ماڈلنگ۔ agentskills.io اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔
شیڈول کی گئی خودکار کارروائیاںبلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔
کام کی تقسیم اور متوازی عملمتوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔
کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیںچھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔
تحقیق کے لیے تیاربیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔
+ +--- + +## فوری انسٹالیشن (Quick Install) + +### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux) + +
+ +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` + +
+ +### ونڈوز (نیٹو، پاور شیل) + +> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔ + +اسے پاور شیل (PowerShell) میں چلائیں: + +
+ +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) +``` + +
+ +انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔ + +اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔ + +> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔ +> +> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔ + +انسٹالیشن کے بعد: + +
+ +```bash +source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc) +hermes # بات چیت شروع کریں! +``` + +
+ +--- + +## آغاز کریں (Getting Started) + +
+ +```bash +hermes # انٹرایکٹو CLI — بات چیت شروع کریں +hermes model # اپنا LLM پرووائیڈر اور ماڈل منتخب کریں +hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں +hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں +hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ) +hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا) +hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں) +hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں +hermes doctor # کسی بھی مسئلے کی تشخیص کریں +``` + +
+ +📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)** + +--- + +## API-کیز اکٹھی کرنے سے بچیں — Nous Portal + +ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے: + +- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model ` کے ذریعے منتخب کریں +- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔ + +نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے: + +
+ +```bash +hermes setup --portal +``` + +
+ +یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔ + +آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔ + +--- + +## CLI بمقابلہ میسجنگ فوری حوالہ + +ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔ + +
+ +| کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) | +| --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- | +| بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں | +| نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` | +| ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` | +| پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` | +| پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` | +| کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` | +| مہارتیں (Skills) براؤز کریں | `/skills` یا `/` | `/` | +| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں | +| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` | + +
+ +مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔ + +--- + +## دستاویزات (Documentation) + +تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں: + +
+ +| سیکشن (Section) | تفصیل (What's Covered) | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں | +| [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز | +| [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز | +| [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ | +| [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن | +| [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز | +| [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا | +| [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار | +| [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں | +| [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام | +| [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے | +| [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز | +| [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز | +| [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) | +| [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات | + +
+ +--- + +## OpenClaw سے منتقلی + +اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔ + +**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔ + +**انسٹالیشن کے بعد کسی بھی وقت:** + +
+ +```bash +hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ) +hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا +hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں +hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں +``` + +
+ +جو چیزیں امپورٹ ہوتی ہیں: + +- **SOUL.md** — پرسونا (persona) فائل +- **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات +- **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/` +- **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns) +- **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری +- **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs) +- **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں +- **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ) + +تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔ + +--- + +## تعاون کریں (Contributing) + +ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔ + +معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں: + +
+ +```bash +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے +./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں +``` + +
+ +مینوئل طریقہ (اوپر والے طریقے کے مساوی): + +
+ +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +uv venv .venv --python 3.11 +source .venv/bin/activate +uv pip install -e ".[all,dev]" +scripts/run_tests.sh +``` + +
+ +--- + +## کمیونٹی (Community) + +- 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch) +- 📚 [سکلز ہب (Skills Hub)](https://agentskills.io) +- 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues) +- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔ +- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔ + +--- + +## لائسنس (License) + +MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔ + +[نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔ + +
diff --git a/README.zh-CN.md b/README.zh-CN.md index e40b65990f0..59b1268f81b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,7 @@ License: MIT Built by Nous Research English + اردو

**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。 diff --git a/acp_adapter/provenance.py b/acp_adapter/provenance.py new file mode 100644 index 00000000000..58b05daf5af --- /dev/null +++ b/acp_adapter/provenance.py @@ -0,0 +1,127 @@ +"""Derive ACP session-provenance metadata from the existing compression chain. + +This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so +existing ACP clients ignore it. It carries no new persisted state: everything +is derived on demand from the ``sessions`` table (``parent_session_id`` / +``end_reason``), which already models compression-continuation chains. + +The ACP/editor ``session_id`` stays the stable public handle. When context +compression rotates the internal Hermes head, ``build_session_provenance`` lets +a client see the previous/current internal ids and the lineage root without +parsing status text, guessing from token drops, or reading ``state.db``. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +# Bound defensive walks; compression chains this deep are pathological. +_MAX_WALK = 100 + + +def build_session_provenance( + db: Any, + acp_session_id: str, + current_hermes_session_id: str, + *, + previous_hermes_session_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Build ``_meta.hermes.sessionProvenance`` for an ACP session. + + Args: + db: A ``SessionDB`` (must expose ``get_session``). + acp_session_id: The stable ACP/editor-facing session handle. + current_hermes_session_id: The live internal Hermes DB session id + (``state.agent.session_id``). + previous_hermes_session_id: The internal id from before the most recent + turn, when known. Supplied by ``prompt()`` to flag a rotation. + + Returns: + A dict suitable for ``{"hermes": {"sessionProvenance": }}`` under + ACP ``_meta``, or ``None`` if the session can't be read. + """ + try: + row = db.get_session(current_hermes_session_id) + except Exception: + return None + if not row: + return None + + parent_id = row.get("parent_session_id") + end_reason = row.get("end_reason") + + # Walk parents to the lineage root and count compression depth. Only + # compression-split parents (parent.end_reason == 'compression') count + # toward depth — delegate/branch children share the parent_session_id + # column but are not compaction boundaries. + root_id = current_hermes_session_id + compression_depth = 0 + cursor_parent = parent_id + seen = {current_hermes_session_id} + for _ in range(_MAX_WALK): + if not cursor_parent or cursor_parent in seen: + break + seen.add(cursor_parent) + try: + prow = db.get_session(cursor_parent) + except Exception: + prow = None + if not prow: + break + root_id = cursor_parent + if prow.get("end_reason") == "compression": + compression_depth += 1 + cursor_parent = prow.get("parent_session_id") + + # A session is a compression continuation when its parent was ended with + # end_reason='compression'. Determine that from the immediate parent. + is_continuation = False + if parent_id: + try: + immediate_parent = db.get_session(parent_id) + except Exception: + immediate_parent = None + if immediate_parent and immediate_parent.get("end_reason") == "compression": + is_continuation = True + + rotated = bool( + previous_hermes_session_id + and previous_hermes_session_id != current_hermes_session_id + ) + + provenance: Dict[str, Any] = { + "acpSessionId": acp_session_id, + "currentHermesSessionId": current_hermes_session_id, + "rootHermesSessionId": root_id, + "parentHermesSessionId": parent_id, + "sessionKind": "continuation" if is_continuation else "root", + "compressionDepth": compression_depth, + } + if previous_hermes_session_id: + provenance["previousHermesSessionId"] = previous_hermes_session_id + if rotated: + # The head moved during the last turn. The only mechanism that rotates + # the internal id mid-turn is compression-driven session splitting. + provenance["reason"] = "compression" + provenance["creatorKind"] = "compression" + + return provenance + + +def session_provenance_meta( + db: Any, + acp_session_id: str, + current_hermes_session_id: str, + *, + previous_hermes_session_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``.""" + prov = build_session_provenance( + db, + acp_session_id, + current_hermes_session_id, + previous_hermes_session_id=previous_hermes_session_id, + ) + if prov is None: + return None + return {"hermes": {"sessionProvenance": prov}} diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 81c22c18774..6901fe28e88 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -71,6 +71,7 @@ from acp_adapter.events import ( make_tool_progress_cb, ) from acp_adapter.permissions import make_approval_callback +from acp_adapter.provenance import session_provenance_meta from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets from acp_adapter.tools import build_tool_complete, build_tool_start @@ -709,8 +710,39 @@ class HermesACPAgent(acp.Agent): exc_info=True, ) - async def _send_session_info_update(self, session_id: str) -> None: - """Send ACP native session metadata after Hermes changes it.""" + def _provenance_meta( + self, + acp_session_id: str, + current_hermes_session_id: str, + previous_hermes_session_id: Optional[str] = None, + ) -> Optional[dict]: + """Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session.""" + try: + return session_provenance_meta( + self.session_manager._get_db(), + acp_session_id, + current_hermes_session_id, + previous_hermes_session_id=previous_hermes_session_id, + ) + except Exception: + logger.debug( + "Could not build ACP session provenance for %s", acp_session_id, exc_info=True + ) + return None + + async def _send_session_info_update( + self, + session_id: str, + *, + current_hermes_session_id: Optional[str] = None, + previous_hermes_session_id: Optional[str] = None, + ) -> None: + """Send ACP native session metadata after Hermes changes it. + + When the internal Hermes head rotated (e.g. compression-driven session + split during a turn), pass ``previous_hermes_session_id`` so the + attached ``_meta.hermes.sessionProvenance`` flags the rotation reason. + """ if not self._conn: return try: @@ -727,10 +759,16 @@ class HermesACPAgent(acp.Agent): # the updated_at since we're emitting this notification precisely # because the title was just refreshed. updated_at = datetime.now(timezone.utc).isoformat() + meta = self._provenance_meta( + session_id, + current_hermes_session_id or session_id, + previous_hermes_session_id, + ) update = SessionInfoUpdate( session_update="session_info_update", title=title if isinstance(title, str) and title.strip() else None, updated_at=updated_at, + field_meta=meta, ) try: await self._conn.session_update( @@ -1081,6 +1119,9 @@ class HermesACPAgent(acp.Agent): session_id=state.session_id, models=self._build_model_state(state), modes=self._session_modes(state), + field_meta=self._provenance_meta( + state.session_id, getattr(state.agent, "session_id", state.session_id) + ), ) async def load_session( @@ -1125,6 +1166,9 @@ class HermesACPAgent(acp.Agent): return LoadSessionResponse( models=self._build_model_state(state), modes=self._session_modes(state), + field_meta=self._provenance_meta( + session_id, getattr(state.agent, "session_id", session_id) + ), ) async def resume_session( @@ -1157,6 +1201,9 @@ class HermesACPAgent(acp.Agent): return ResumeSessionResponse( models=self._build_model_state(state), modes=self._session_modes(state), + field_meta=self._provenance_meta( + state.session_id, getattr(state.agent, "session_id", state.session_id) + ), ) async def cancel(self, session_id: str, **kwargs: Any) -> None: @@ -1494,6 +1541,11 @@ class HermesACPAgent(acp.Agent): logger.debug("Could not clear ACP session context", exc_info=True) try: + # Snapshot the internal Hermes DB session id before the turn so we + # can detect a compression-driven session rotation afterwards. The + # ACP `session_id` stays the stable client handle; agent.session_id + # is the live internal head that compression may rotate. + pre_turn_hermes_id = getattr(state.agent, "session_id", None) # Wrap the executor call in a fresh copy of the current context so # concurrent ACP sessions on the shared ThreadPoolExecutor don't # stomp on each other's ContextVar writes (HERMES_SESSION_KEY in @@ -1512,8 +1564,41 @@ class HermesACPAgent(acp.Agent): # Persist updated history so sessions survive process restarts. self.session_manager.save_session(session_id) + # Detect a compression-driven internal session rotation. If the agent's + # DB head moved during the turn, emit a session_info_update carrying + # _meta.hermes.sessionProvenance so ACP clients can render the boundary + # and keep old/new ids in lineage. The ACP session_id is unchanged. + post_turn_hermes_id = getattr(state.agent, "session_id", None) + if ( + conn + and post_turn_hermes_id + and pre_turn_hermes_id + and post_turn_hermes_id != pre_turn_hermes_id + ): + try: + await self._send_session_info_update( + session_id, + current_hermes_session_id=post_turn_hermes_id, + previous_hermes_session_id=pre_turn_hermes_id, + ) + except Exception: + logger.debug( + "Could not emit ACP provenance update after rotation for %s", + session_id, + exc_info=True, + ) + final_response = result.get("final_response", "") - if final_response: + cancelled = bool(state.cancel_event and state.cancel_event.is_set()) + interrupted = bool(result.get("interrupted")) or cancelled + # Hermes' local "waiting for model response" interrupt status is metadata, + # not assistant prose — clients get cancellation from stop_reason instead. + from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX + + suppress_interrupt_response = interrupted and final_response.startswith( + INTERRUPT_WAITING_FOR_MODEL_PREFIX + ) + if final_response and not suppress_interrupt_response: try: from agent.title_generator import maybe_auto_title @@ -1534,7 +1619,12 @@ class HermesACPAgent(acp.Agent): ) except Exception: logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True) - if final_response and conn and (not streamed_message or result.get("response_transformed")): + if ( + final_response + and conn + and not suppress_interrupt_response + and (not streamed_message or result.get("response_transformed")) + ): # Deliver the final response when streaming did not already send it, # or when a plugin hook transformed the response after streaming # finished (e.g. transform_llm_output) — otherwise the appended / @@ -1576,7 +1666,7 @@ class HermesACPAgent(acp.Agent): await self._send_usage_update(state) - stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn" + stop_reason = "cancelled" if cancelled else "end_turn" return PromptResponse(stop_reason=stop_reason, usage=usage) # ---- Slash commands (headless) ------------------------------------------- diff --git a/agent/agent_init.py b/agent/agent_init.py index 62de3f2c540..30bb6d83705 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -169,6 +169,7 @@ def init_agent( save_trajectories: bool = False, verbose_logging: bool = False, quiet_mode: bool = False, + tool_progress_mode: str = "all", ephemeral_system_prompt: str = None, log_prefix_chars: int = 100, log_prefix: str = "", @@ -280,6 +281,7 @@ def init_agent( agent.save_trajectories = save_trajectories agent.verbose_logging = verbose_logging agent.quiet_mode = quiet_mode + agent.tool_progress_mode = tool_progress_mode agent.ephemeral_system_prompt = ephemeral_system_prompt agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. agent._user_id = user_id # Platform user identifier (gateway sessions) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 3e4e92a33a8..f9bfb7a4319 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1846,6 +1846,27 @@ def repair_tool_call(agent, tool_name: str) -> str | None: if not tool_name: return None + # VolcEngine api/plan workaround (issue #33007): the endpoint's + # protocol-translation layer occasionally leaks raw XML attribute + # fragments into tool_use.name, e.g. + # `terminal" parameter="command" string="true` + # `execute_code" parameter="code" string="true` + # `session_search" parameter="session_id" string="true` + # We trim at the first unambiguous XML/quote character so the rest + # of the repair pipeline (lowercase / snake_case / fuzzy match) + # can resolve the cleaned name to a real tool. + # + # Crucially we DO NOT split on whitespace: legitimate inputs like + # "write file" must keep flowing through ``_norm`` -> ``write_file`` + # (covered by test_space_to_underscore in + # tests/run_agent/test_repair_tool_call_name.py). + for _xml_sep in ('"', "'", "<", ">"): + _idx = tool_name.find(_xml_sep) + if _idx > 0: + tool_name = tool_name[:_idx] + if not tool_name: + return None + def _norm(s: str) -> str: return s.lower().replace("-", "_").replace(" ", "_") diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a4a211843ee..bf3f4aef859 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2301,3 +2301,43 @@ def build_anthropic_kwargs( kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)} return kwargs + + +# Keys that belong exclusively to the OpenAI Responses / Codex API shape. +# The Anthropic Messages SDK (``messages.create()`` / ``messages.stream()``) +# raises ``TypeError: ... got an unexpected keyword argument`` on any of them. +_RESPONSES_ONLY_KWARGS = frozenset( + {"instructions", "input", "store", "parallel_tool_calls"} +) + + +def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any: + """Drop Responses-API-only keys before an Anthropic Messages SDK call. + + Defensive boundary guard for #31673: under rare api_mode-flip races + (e.g. a concurrent auxiliary call mutating a shared agent between the + kwargs build and the stream dispatch), a Responses-shaped payload + carrying ``instructions=`` can reach ``messages.stream()`` / + ``messages.create()``. The Anthropic SDK rejects it with a + non-retryable ``TypeError`` that nukes the whole turn and propagates + the entire fallback chain. + + Mutates ``api_kwargs`` in place and returns it. When a foreign key is + present we log a WARNING so the underlying race stays visible in the + wild instead of being silently papered over. + """ + if not isinstance(api_kwargs, dict): + return api_kwargs + leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs) + if leaked: + for _key in leaked: + api_kwargs.pop(_key, None) + logger.warning( + "%sStripped Responses-only kwarg(s) %s from an Anthropic Messages " + "call (api_mode flip race — see #31673). The call will proceed; " + "this breadcrumb means a kwargs build ran under a Responses " + "api_mode while dispatch ran under anthropic_messages.", + log_prefix, + sorted(leaked), + ) + return api_kwargs diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 79352e2fe3a..c47c3a4a1d2 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -637,54 +637,6 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str: # calls to the Codex Responses API so callers don't need any changes. -def _convert_content_for_responses(content: Any) -> Any: - """Convert chat.completions content to Responses API format. - - chat.completions uses: - {"type": "text", "text": "..."} - {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} - - Responses API uses: - {"type": "input_text", "text": "..."} - {"type": "input_image", "image_url": "data:image/png;base64,..."} - - If content is a plain string, it's returned as-is (the Responses API - accepts strings directly for text-only messages). - """ - if isinstance(content, str): - return content - if not isinstance(content, list): - return str(content) if content else "" - - converted: List[Dict[str, Any]] = [] - for part in content: - if not isinstance(part, dict): - continue - ptype = part.get("type", "") - if ptype == "text": - converted.append({"type": "input_text", "text": part.get("text", "")}) - elif ptype == "image_url": - # chat.completions nests the URL: {"image_url": {"url": "..."}} - image_data = part.get("image_url", {}) - url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data) - entry: Dict[str, Any] = {"type": "input_image", "image_url": url} - # Preserve detail if specified - detail = image_data.get("detail") if isinstance(image_data, dict) else None - if detail: - entry["detail"] = detail - converted.append(entry) - elif ptype in {"input_text", "input_image"}: - # Already in Responses format — pass through - converted.append(part) - else: - # Unknown content type — try to preserve as text - text = part.get("text", "") - if text: - converted.append({"type": "input_text", "text": text}) - - return converted or "" - - class _CodexCompletionsAdapter: """Drop-in shim that accepts chat.completions.create() kwargs and routes them through the Codex Responses streaming API.""" @@ -697,26 +649,37 @@ class _CodexCompletionsAdapter: messages = kwargs.get("messages", []) model = kwargs.get("model", self._model) - # Separate system/instructions from conversation messages. - # Convert chat.completions multimodal content blocks to Responses - # API format (input_text / input_image instead of text / image_url). + # Separate system/instructions from replayable conversation messages, + # then route the rest through the SINGLE shared chat->Responses + # converter used by the main agent transport + # (agent/transports/codex.py). Maintaining a private conversion loop + # here let chat-style messages with role="tool" leak straight into + # Responses input[] — which the Responses API rejects with + # "Invalid value: 'tool'. Supported values are: 'assistant', 'system', + # 'developer', and 'user'." (issue #5709, hit hard by flush_memories() + # / compression replaying real session history that includes assistant + # tool_calls + role="tool" results). The shared converter encodes + # assistant tool calls as `function_call` items and tool results as + # `function_call_output` items with a valid call_id, so every + # Responses path normalizes tool history identically and cannot drift. + from agent.codex_responses_adapter import _chat_messages_to_responses_input + instructions = "You are a helpful assistant." - input_msgs: List[Dict[str, Any]] = [] + replay_messages: List[Dict[str, Any]] = [] for msg in messages: role = msg.get("role", "user") content = msg.get("content") or "" if role == "system": instructions = content if isinstance(content, str) else str(content) else: - input_msgs.append({ - "role": role, - "content": _convert_content_for_responses(content), - }) + replay_messages.append(msg) + + input_items = _chat_messages_to_responses_input(replay_messages) resp_kwargs: Dict[str, Any] = { "model": model, "instructions": instructions, - "input": input_msgs or [{"role": "user", "content": ""}], + "input": input_items or [{"role": "user", "content": ""}], "store": False, } @@ -2513,6 +2476,25 @@ def _is_connection_error(exc: Exception) -> bool: return False +def _is_transient_transport_error(exc: Exception) -> bool: + """Return True for a one-off transport blip worth retrying ONCE on the + same provider before any provider/model fallback. + + Covers connection/streaming-close errors (via the canonical + ``_is_connection_error`` detector, shared so the two cannot drift) plus a + pure 5xx/408 HTTP status. Deliberately narrow: this is the "retry the + same target once" gate, distinct from ``_is_payment_error`` / + ``_is_auth_error`` / ``_is_rate_limit_error`` which the except-chain + handles by switching provider, refreshing creds, or rotating the pool. + """ + if _is_connection_error(exc): + return True + status = getattr(exc, "status_code", None) or getattr( + getattr(exc, "response", None), "status_code", None + ) + return isinstance(status, int) and (status == 408 or 500 <= status < 600) + + def _is_auth_error(exc: Exception) -> bool: """Detect auth failures that should trigger provider-specific refresh.""" status = getattr(exc, "status_code", None) @@ -5184,8 +5166,28 @@ def call_llm( # Handle unsupported temperature, max_tokens vs max_completion_tokens retry, # then payment fallback. try: - return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + # Retry ONCE on the same provider for a one-off transient transport + # blip (streaming-close / incomplete chunked read / 5xx / 408) before + # the except-chain below escalates to provider/model fallback. A + # single dropped connection shouldn't abandon an otherwise-healthy + # provider. A second failure (or any non-transient error) falls + # through to ``first_err`` and the existing fallback handling + # unchanged. This is the unified home for the transient retry that + # every auxiliary task (compression, memory flush, title-gen, + # session-search, vision) shares. (PR #16587) + try: + return _validate_llm_response( + client.chat.completions.create(**kwargs), task) + except Exception as transient_err: + if not _is_transient_transport_error(transient_err): + raise + logger.info( + "Auxiliary %s: transient transport error; retrying once on " + "the same provider before fallback: %s", + task or "call", transient_err, + ) + return _validate_llm_response( + client.chat.completions.create(**kwargs), task) except Exception as first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) @@ -5651,8 +5653,22 @@ async def async_call_llm( kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"]) try: - return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + # Retry ONCE on the same provider for a transient transport blip + # before the except-chain escalates to fallback — see call_llm() + # for the rationale. (PR #16587) + try: + return _validate_llm_response( + await client.chat.completions.create(**kwargs), task) + except Exception as transient_err: + if not _is_transient_transport_error(transient_err): + raise + logger.info( + "Auxiliary %s (async): transient transport error; retrying " + "once on the same provider before fallback: %s", + task or "call", transient_err, + ) + return _validate_llm_response( + await client.chat.completions.create(**kwargs), task) except Exception as first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) diff --git a/agent/background_review.py b/agent/background_review.py index bf99ee52845..d9f6ea5950d 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -449,6 +449,17 @@ def _run_review_in_thread( # if a future code path bypasses the cache. review_agent.session_start = agent.session_start review_agent.session_id = agent.session_id + # Never let the review fork compress. It shares the parent's + # session_id, so if it won a compression race it would rotate the + # parent into a NEW child that the gateway never adopts (the fork + # is single-lifecycle and dies right after this run_conversation). + # The foreground turn would then start from the stale parent and + # compress it again, leaving the same parent with two sibling + # children (issue #38727). Review also needs full context to + # produce a good memory/skill summary — compressing would strip + # detail. Both compression triggers in conversation_loop.py gate on + # agent.compression_enabled, so this short-circuits both paths. + review_agent.compression_enabled = False from model_tools import get_tool_definitions from hermes_cli.plugins import ( diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index cbbc9139462..ce066d55640 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -139,6 +139,15 @@ def interruptible_api_call(agent, api_kwargs: dict): result = {"response": None, "error": None} request_client_holder = {"client": None, "owner_tid": None} request_client_lock = threading.Lock() + # Request-local cancellation flag. Distinct from agent._interrupt_requested + # because that flag is cleared at run_conversation() turn boundaries, but + # this daemon worker thread can outlive the turn (the gateway caches + # AIAgent instances per session). Tracks whether THIS specific request was + # cancelled by the main thread's interrupt handler, so the transport error + # that is the expected consequence of our own force-close isn't misread as + # a network bug and surfaced to the caller. (PR #6600 — cascading interrupt + # hang.) + _request_cancelled = {"value": False} def _set_request_client(client): with request_client_lock: @@ -229,6 +238,17 @@ def interruptible_api_call(agent, api_kwargs: dict): ) result["response"] = request_client.chat.completions.create(**api_kwargs) except Exception as e: + # If the request was cancelled by the main thread's interrupt + # handler, the transport error is the expected consequence of our + # own force-close, NOT a network bug. Swallow it instead of + # surfacing — the main thread raises InterruptedError. (#6600) + if _request_cancelled["value"]: + logger.debug( + "Non-streaming worker caught %s after request cancellation — " + "exiting without surfacing a network error.", + type(e).__name__, + ) + return result["error"] = e finally: _close_request_client_once("request_complete") @@ -506,6 +526,14 @@ def interruptible_api_call(agent, api_kwargs: dict): break if agent._interrupt_requested: + # Mark THIS request cancelled before force-closing so the worker's + # exception handler recognizes the forced transport error as a + # cancel and exits cleanly instead of surfacing a network error or + # (in the streaming path) burning full retry cycles. (#6600) + _request_cancelled["value"] = True + logger.debug( + "Force-closing httpx client due to interrupt (not a network error)." + ) # Force-close the in-flight worker-local HTTP connection to stop # token generation without poisoning the shared client used to # seed future retries. @@ -1625,6 +1653,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= result = {"response": None, "error": None, "partial_tool_names": []} request_client_holder = {"client": None, "diag": None, "owner_tid": None} request_client_lock = threading.Lock() + # Request-local cancellation flag — see interruptible_api_call for the full + # rationale. The streaming retry loop is where the 7-minute cascading- + # interrupt hang originated: a force-close raised RemoteProtocolError, the + # loop classified it as a transient network error, and burned full retry + # cycles (and emitted "reconnecting" noise) on a request the user already + # cancelled. The token lets the worker recognize its own forced close and + # exit immediately instead of retrying. (PR #6600.) + _request_cancelled = {"value": False} def _set_request_client(client): with request_client_lock: @@ -1950,6 +1986,58 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= "(possible upstream error or malformed SSE response)." ) + # A stream that delivered a tool call but only partial/unparseable + # JSON args splits into two very different cases: + # + # 1. Provider sent finish_reason="length" → a genuine output-cap + # truncation. Boosting max_tokens on retry is the right move. + # + # 2. Provider sent NO finish_reason (the SSE simply stopped after + # the opening "{" with no terminator and no [DONE]) → the + # upstream dropped/stalled the connection mid tool-call. This + # is NOT an output cap — the model never reported hitting one. + # Some dedicated endpoints (e.g. NVIDIA Nemotron Ultra on the + # Nous dedicated endpoint) stall for minutes during large + # tool-arg generation, then close the stream cleanly without a + # finish_reason. Stamping "length" here sends it down the + # max_tokens-boost truncation path, which retries 3× to no + # effect and finally reports the misleading "Response truncated + # due to output length limit" — the red herring this guards + # against. Route it through the partial-stream-stub path + # instead so the loop reports an honest mid-tool-call stream + # drop and fails fast rather than escalating output budget. + _tool_args_dropped_no_finish = has_truncated_tool_args and finish_reason is None + if _tool_args_dropped_no_finish: + _dropped_names = [ + (tool_calls_acc[idx]["function"]["name"] or "?") + for idx in sorted(tool_calls_acc) + ] + logger.warning( + "Stream ended with no finish_reason while a tool call's " + "arguments were still incomplete (tools=%s); treating as a " + "mid-tool-call stream drop, not an output-length truncation.", + _dropped_names, + ) + full_reasoning = "".join(reasoning_parts) or None + mock_message = SimpleNamespace( + role=role, + content=full_content, + tool_calls=None, + reasoning_content=full_reasoning, + ) + mock_choice = SimpleNamespace( + index=0, + message=mock_message, + finish_reason=FINISH_REASON_LENGTH, + ) + return SimpleNamespace( + id=PARTIAL_STREAM_STUB_ID, + model=model_name, + choices=[mock_choice], + usage=usage_obj, + _dropped_tool_names=_dropped_names or None, + ) + effective_finish_reason = finish_reason or "stop" if has_truncated_tool_args: effective_finish_reason = "length" @@ -1988,6 +2076,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # Per-attempt diagnostic dict for the retry block to consume. _diag = agent._stream_diag_init() request_client_holder["diag"] = _diag + # Defensive: strip Responses-only kwargs (instructions, input, ...) + # that can leak in under an api_mode-flip race. The Anthropic SDK + # raises a non-retryable TypeError on them, killing the turn. See + # #31673 / sanitize_anthropic_kwargs(). + from agent.anthropic_adapter import sanitize_anthropic_kwargs + sanitize_anthropic_kwargs( + api_kwargs, log_prefix=getattr(agent, "log_prefix", "") + ) # Use the Anthropic SDK's streaming context manager with agent._anthropic_client.messages.stream(**api_kwargs) as stream: # The Anthropic SDK exposes the raw httpx response on @@ -2078,6 +2174,21 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= result["response"] = _call_chat_completions() return # success except Exception as e: + # If the main poll loop force-closed this request because + # of an interrupt, the resulting transport error is the + # expected consequence of our own close — NOT a transient + # network error. Exit immediately: no retry, no fallback, + # no "reconnecting" status. The outer poll loop raises + # InterruptedError. This is the fix for the cascading- + # interrupt hang where doomed retries burned full + # stream-stale-timeout cycles. (#6600) + if _request_cancelled["value"]: + logger.debug( + "Streaming worker caught %s after request " + "cancellation — exiting without retry.", + type(e).__name__, + ) + return _is_timeout = isinstance( e, (_httpx.ReadTimeout, _httpx.ConnectTimeout, _httpx.PoolTimeout) ) @@ -2387,6 +2498,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= ) if agent._interrupt_requested: + # Mark THIS request cancelled before force-closing so the worker's + # exception handler recognizes the forced transport error as a + # cancel and exits without retrying or surfacing a network error. + # (#6600) + _request_cancelled["value"] = True + logger.debug( + "Force-closing streaming httpx client due to interrupt " + "(not a network error)." + ) try: if agent.api_mode == "anthropic_messages": agent._anthropic_client.close() diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 71c7944c772..98d226b46af 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -553,6 +553,22 @@ class ContextCompressor(ContextEngine): self.last_rough_tokens_when_real_prompt_fit = 0 self.awaiting_real_usage_after_compression = False + def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None: + """Clear per-session compaction state at a real session boundary. + + ``_previous_summary`` is per-session iterative-summary state. It is + cleared on ``on_session_reset()`` (/new, /reset), but session *end* + (CLI exit, gateway expiry, session-id rotation) goes through + ``on_session_end()`` instead — which inherited a no-op from + ``ContextEngine``. Without clearing here, a cron/background session's + summary could survive on a reused compressor instance and leak into the + next live session via the ``_generate_summary()`` iterative-update path + (#38788). ``compress()`` already guards the leak at the point of use; + this is defense-in-depth that drops the stale summary the moment the + owning session ends. + """ + self._previous_summary = None + def update_model( self, model: str, @@ -1818,6 +1834,41 @@ The user has requested that this compaction PRIORITISE preserving all informatio accumulated += msg_tokens cut_idx = i + # If the backward walk never broke early because the entire transcript + # fits within soft_ceiling, accumulated now holds the total transcript + # size. Without intervention _ensure_last_user_message_in_tail pushes + # cut_idx forward to include the last user message, and the caller's + # compress_start >= compress_end guard either returns unchanged (no-op) + # or compresses a single message — both of which trigger the infinite + # compaction loop described in #40803. + # + # Fix: when the whole transcript fits in soft_ceiling, compute a + # meaningful cut point using the raw (non-inflated) budget so that + # compression actually summarizes a worthwhile middle section. + if cut_idx <= head_end and accumulated <= soft_ceiling and accumulated > 0: + # The entire compressable region fits in the soft ceiling. + # Re-walk with the raw budget (no 1.5x multiplier) to find a + # split that gives the summarizer something useful. + raw_budget = token_budget + raw_accumulated = 0 + for j in range(n - 1, head_end - 1, -1): + raw_msg = messages[j] + raw_content = raw_msg.get("content") or "" + raw_len = _content_length_for_budget(raw_content) + raw_tok = raw_len // _CHARS_PER_TOKEN + 10 + for tc in raw_msg.get("tool_calls") or []: + if isinstance(tc, dict): + args = tc.get("function", {}).get("arguments", "") + raw_tok += len(args) // _CHARS_PER_TOKEN + if raw_accumulated + raw_tok > raw_budget and (n - j) >= min_tail: + cut_idx = j + break + raw_accumulated += raw_tok + cut_idx = j + # If the raw-budget walk also consumed everything (very small + # transcript), fall through — the existing fallback logic below + # will still force a minimal cut after head_end. + # Ensure we protect at least min_tail messages fallback_cut = n - min_tail cut_idx = min(cut_idx, fallback_cut) @@ -1920,6 +1971,21 @@ The user has requested that this compaction PRIORITISE preserving all informatio compress_end = self._find_tail_cut_by_tokens(messages, compress_start) if compress_start >= compress_end: + # No compressable window — the entire transcript fits within + # the tail budget (soft_ceiling). Without recording this as + # an ineffective compression the anti-thrashing guard in + # should_compress() never fires and every subsequent turn + # re-triggers a no-op compression loop. (#40803) + self._ineffective_compression_count += 1 + self._last_compression_savings_pct = 0.0 + if not self.quiet_mode: + logger.warning( + "Compression skipped: compress_start (%d) >= compress_end (%d) " + "— transcript fits within tail budget, nothing to compress. " + "ineffective_compression_count=%d", + compress_start, compress_end, + self._ineffective_compression_count, + ) return messages turns_to_summarize = messages[compress_start:compress_end] @@ -1940,6 +2006,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio if summary_body and not self._previous_summary: self._previous_summary = summary_body turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end] + elif self._previous_summary: + # No handoff summary found in the current messages, but + # _previous_summary is non-empty — it was set by a different + # (now-ended) session (e.g., a cron job, a prior /new). Discard + # it so _generate_summary() does not inject cross-session content + # into the summarizer prompt via the iterative-update path. + self._previous_summary = None if not self.quiet_mode: logger.info( diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 06257ffd2e7..913c0e25d91 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -507,12 +507,29 @@ def compress_context( agent._session_db.end_session(agent.session_id, "compression") old_session_id = agent.session_id agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + # Ordering contract: the agent thread updates the contextvar here; + # the gateway propagates to SessionEntry after run_in_executor returns. try: from gateway.session_context import set_current_session_id set_current_session_id(agent.session_id) except Exception: os.environ["HERMES_SESSION_ID"] = agent.session_id + # The gateway/tools session context (ContextVar + env) and the + # logging session context are SEPARATE mechanisms. The call above + # moves the former; the ``[session_id]`` tag on log lines comes + # from ``hermes_logging._session_context`` (set once per turn in + # conversation_loop.py). Without this, post-rotation log lines in + # the same turn keep the STALE old id while the message/DB/gateway + # state carry the new one — breaking log correlation exactly at the + # compaction boundary (see #34089). Guarded separately so a logging + # failure can never regress the routing update above. + try: + from hermes_logging import set_session_context + + set_session_context(agent.session_id) + except Exception: + pass agent._session_db_created = False agent._session_db.create_session( session_id=agent.session_id, diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 330d37df270..73bed6b0670 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -31,6 +31,8 @@ from agent.codex_responses_adapter import _summarize_user_message_for_log from agent.display import KawaiiSpinner from agent.error_classifier import FailoverReason, classify_api_error from agent.iteration_budget import IterationBudget +from agent.turn_context import build_turn_context +from agent.turn_retry_state import TurnRetryState from agent.memory_manager import build_memory_context_block from agent.message_sanitization import ( _repair_tool_call_arguments, @@ -63,6 +65,11 @@ from utils import base_url_host_matches, env_var_enabled logger = logging.getLogger(__name__) +# Stable prefix of the local interrupt status string emitted when a turn is +# cancelled while waiting on the provider. Surfaces (ACP, TUI) match on this +# to treat it as cancellation metadata rather than assistant prose. +INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response (" + def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]: """Return a user-facing error when Ollama is loaded with too little context.""" @@ -389,376 +396,43 @@ def run_conversation( Returns: Dict: Complete conversation result with final response and message history """ - # Guard stdio against OSError from broken pipes (systemd/headless/daemon). - # Installed once, transparent when streams are healthy, prevents crash on write. - _install_safe_stdio() - - agent._ensure_db_session() - - # Tell auxiliary_client what the live main provider/model are for - # this turn. Used by tools whose behaviour depends on the active - # main model (e.g. vision_analyze's native fast path) so they see - # the CLI/gateway override instead of the stale config.yaml - # default. Idempotent — fine to call every turn. - try: - from agent.auxiliary_client import set_runtime_main - set_runtime_main( - getattr(agent, "provider", "") or "", - getattr(agent, "model", "") or "", - base_url=getattr(agent, "base_url", "") or "", - api_key=getattr(agent, "api_key", "") or "", - api_mode=getattr(agent, "api_mode", "") or "", - ) - except Exception: - pass - - # Tag all log records on this thread with the session ID so - # ``hermes logs --session `` can filter a single conversation. - set_session_context(agent.session_id) - - # Bind the skill write-origin ContextVar for this thread so tool - # handlers (e.g. skill_manage create) can tell whether they are - # running inside the background agent-improvement review fork vs. - # a foreground user-directed turn. Set at the top of each call; - # the review fork runs on its own thread with a fresh context, - # so the foreground value here does not leak into it. - set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool")) - - # If the previous turn activated fallback, restore the primary - # runtime so this turn gets a fresh attempt with the preferred model. - # No-op when _fallback_activated is False (gateway, first turn, etc.). - agent._restore_primary_runtime() - - # Sanitize surrogate characters from user input. Clipboard paste from - # rich-text editors (Google Docs, Word, etc.) can inject lone surrogates - # that are invalid UTF-8 and crash JSON serialization in the OpenAI SDK. - if isinstance(user_message, str): - user_message = _sanitize_surrogates(user_message) - if isinstance(persist_user_message, str): - persist_user_message = _sanitize_surrogates(persist_user_message) - - # Store stream callback for _interruptible_api_call to pick up - agent._stream_callback = stream_callback - agent._persist_user_message_idx = None - agent._persist_user_message_override = persist_user_message - # Generate unique task_id if not provided to isolate VMs between concurrent tasks - effective_task_id = task_id or str(uuid.uuid4()) - # Expose the active task_id so tools running mid-turn (e.g. delegate_task - # in delegate_tool.py) can identify this agent for the cross-agent file - # state registry. Set BEFORE any tool dispatch so snapshots taken at - # child-launch time see the parent's real id, not None. - agent._current_task_id = effective_task_id - turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}" - agent._current_turn_id = turn_id - agent._current_api_request_id = "" - - # Reset retry counters and iteration budget at the start of each turn - # so subagent usage from a previous turn doesn't eat into the next one. - agent._invalid_tool_retries = 0 - agent._invalid_json_retries = 0 - agent._empty_content_retries = 0 - agent._incomplete_scratchpad_retries = 0 - agent._codex_incomplete_retries = 0 - agent._thinking_prefill_retries = 0 - agent._post_tool_empty_retried = False - agent._last_content_with_tools = None - agent._last_content_tools_all_housekeeping = False - agent._mute_post_response = False - agent._unicode_sanitization_passes = 0 - agent._tool_guardrails.reset_for_turn() - agent._tool_guardrail_halt_decision = None - # True until the server rejects an image_url content part with an error - # like "Only 'text' content type is supported." Set to False on first - # rejection and kept False for the rest of the session so we never re-send - # images to a text-only endpoint. Scoped per `_run()` call, not per instance. - agent._vision_supported = True - - # Pre-turn connection health check: detect and clean up dead TCP - # connections left over from provider outages or dropped streams. - # This prevents the next API call from hanging on a zombie socket. - if agent.api_mode != "anthropic_messages": - try: - if agent._cleanup_dead_connections(): - agent._emit_status( - "🔌 Detected stale connections from a previous provider " - "issue — cleaned up automatically. Proceeding with fresh " - "connection." - ) - except Exception: - pass - # Replay compression warning through status_callback for gateway - # platforms (the callback was not wired during __init__). - if agent._compression_warning: - agent._replay_compression_warning() - agent._compression_warning = None # send once - - # NOTE: _turns_since_memory and _iters_since_skill are NOT reset here. - # They are initialized in __init__ and must persist across run_conversation - # calls so that nudge logic accumulates correctly in CLI mode. - agent.iteration_budget = IterationBudget(agent.max_iterations) - - # Log conversation turn start for debugging/observability - _preview_text = _summarize_user_message_for_log(user_message) - _msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text - _msg_preview = _msg_preview.replace("\n", " ") - logger.info( - "conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r", - agent.session_id or "none", agent.model, agent.provider or "unknown", - agent.platform or "unknown", len(conversation_history or []), - _msg_preview, + # ── Per-turn setup (the prologue) ── + # All once-per-turn setup — stdio guarding, retry-counter resets, user + # message sanitization, todo/nudge hydration, system-prompt restore-or- + # build, crash-resilience persistence, preflight compression, the + # ``pre_llm_call`` plugin hook, and external-memory prefetch — lives in + # ``build_turn_context``. It mutates ``agent`` exactly as the inline code + # did and returns the locals the loop below reads back. See + # ``agent/turn_context.py``. + _ctx = build_turn_context( + agent, + user_message, + system_message, + conversation_history, + task_id, + stream_callback, + persist_user_message, + restore_or_build_system_prompt=_restore_or_build_system_prompt, + install_safe_stdio=_install_safe_stdio, + sanitize_surrogates=_sanitize_surrogates, + summarize_user_message_for_log=_summarize_user_message_for_log, + set_session_context=set_session_context, + set_current_write_origin=set_current_write_origin, + ra=_ra, ) + user_message = _ctx.user_message + original_user_message = _ctx.original_user_message + messages = _ctx.messages + conversation_history = _ctx.conversation_history + active_system_prompt = _ctx.active_system_prompt + effective_task_id = _ctx.effective_task_id + turn_id = _ctx.turn_id + current_turn_user_idx = _ctx.current_turn_user_idx + _should_review_memory = _ctx.should_review_memory + _plugin_user_context = _ctx.plugin_user_context + _ext_prefetch_cache = _ctx.ext_prefetch_cache - # Initialize conversation (copy to avoid mutating the caller's list) - messages = list(conversation_history) if conversation_history else [] - - # Hydrate todo store from conversation history (gateway creates a fresh - # AIAgent per message, so the in-memory store is empty -- we need to - # recover the todo state from the most recent todo tool response in history) - if conversation_history and not agent._todo_store.has_items(): - agent._hydrate_todo_store(conversation_history) - - # Hydrate per-session nudge counters from persisted history. - # Gateway creates a fresh AIAgent per inbound message (cache miss / - # 1h idle eviction / config-signature mismatch / process restart), so - # _turns_since_memory and _user_turn_count start at 0 every turn and - # the memory.nudge_interval trigger may never be reached. Reconstruct - # an effective count from prior user turns in conversation_history. - # Idempotent: a cached agent that already accumulated counters keeps - # them; only a freshly-built agent with empty in-memory state hydrates. - # See issue #22357. - if conversation_history and agent._user_turn_count == 0: - prior_user_turns = sum( - 1 for m in conversation_history if m.get("role") == "user" - ) - if prior_user_turns > 0: - agent._user_turn_count = prior_user_turns - if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0: - # % preserves original 1-in-N cadence rather than firing a - # review immediately on resume (which would surprise users - # whose session happened to land just past a multiple of N). - agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval - - - # Prefill messages (few-shot priming) are injected at API-call time only, - # never stored in the messages list. This keeps them ephemeral: they won't - # be saved to session DB, session logs, or batch trajectories, but they're - # automatically re-applied on every API call (including session continuations). - - # Track user turns for memory flush and periodic nudge logic - agent._user_turn_count += 1 - - # Reset the streaming context scrubber at the top of each turn so a - # hung span from a prior interrupted stream can't taint this turn's - # output. - scrubber = getattr(agent, "_stream_context_scrubber", None) - if scrubber is not None: - scrubber.reset() - # Reset the think scrubber for the same reason — an interrupted - # prior stream may have left us inside an unterminated block. - think_scrubber = getattr(agent, "_stream_think_scrubber", None) - if think_scrubber is not None: - think_scrubber.reset() - - # Preserve the original user message (no nudge injection). - original_user_message = persist_user_message if persist_user_message is not None else user_message - - # Track memory nudge trigger (turn-based, checked here). - # Skill trigger is checked AFTER the agent loop completes, based on - # how many tool iterations THIS turn used. - _should_review_memory = False - if (agent._memory_nudge_interval > 0 - and "memory" in agent.valid_tool_names - and agent._memory_store): - agent._turns_since_memory += 1 - if agent._turns_since_memory >= agent._memory_nudge_interval: - _should_review_memory = True - agent._turns_since_memory = 0 - - # Add user message - user_msg = {"role": "user", "content": user_message} - messages.append(user_msg) - current_turn_user_idx = len(messages) - 1 - agent._persist_user_message_idx = current_turn_user_idx - - if not agent.quiet_mode: - _print_preview = _summarize_user_message_for_log(user_message) - agent._safe_print(f"💬 Starting conversation: '{_print_preview[:60]}{'...' if len(_print_preview) > 60 else ''}'") - - # ── System prompt (cached per session for prefix caching) ── - # Built once on first call, reused for all subsequent calls. - # Only rebuilt after context compression events (which invalidate - # the cache and reload memory from disk). - # - # For continuing sessions (gateway creates a fresh AIAgent per - # message), we load the stored system prompt from the session DB - # instead of rebuilding. Rebuilding would pick up memory changes - # from disk that the model already knows about (it wrote them!), - # producing a different system prompt and breaking the Anthropic - # prefix cache. - if agent._cached_system_prompt is None: - _restore_or_build_system_prompt(agent, system_message, conversation_history) - - active_system_prompt = agent._cached_system_prompt - - # Crash-resilience: persist the inbound user turn as soon as the session row - # has a valid system prompt, before any provider call or tool execution can - # hang/kill the process. The normal end-of-turn persist still runs later; - # _last_flushed_db_idx makes this idempotent and prevents duplicate rows. - try: - agent._persist_session(messages, conversation_history) - except Exception: - logger.warning( - "Early turn-start session persistence failed for session=%s", - agent.session_id or "none", - exc_info=True, - ) - - # ── Preflight context compression ── - # Before entering the main loop, check if the loaded conversation - # history already exceeds the model's context threshold. This handles - # cases where a user switches to a model with a smaller context window - # while having a large existing session — compress proactively rather - # than waiting for an API error (which might be caught as a non-retryable - # 4xx and abort the request entirely). - if ( - agent.compression_enabled - and len(messages) > agent.context_compressor.protect_first_n - + agent.context_compressor.protect_last_n + 1 - ): - # Include tool schema tokens — with many tools these can add - # 20-30K+ tokens that the old sys+msg estimate missed entirely. - _preflight_tokens = estimate_request_tokens_rough( - messages, - system_prompt=active_system_prompt or "", - tools=agent.tools or None, - ) - _compressor = agent.context_compressor - _defer_preflight = getattr( - _compressor, - "should_defer_preflight_to_real_usage", - lambda _tokens: False, - ) - _preflight_deferred = _defer_preflight(_preflight_tokens) - - if not _preflight_deferred: - # Keep the CLI/ACP context display in sync with what preflight - # actually measured. The status bar reads - # ``compressor.last_prompt_tokens``, which otherwise only updates - # from a *successful* API response. When the conversation has grown - # since the last successful call — or when compression then fails - # (e.g. the auxiliary summary model times out) and no fresh usage - # arrives — the bar stays stuck at the old, smaller value while - # preflight reports a much larger number, looking out of sync. - # Seed it with the fresh estimate (only ever revising upward; a real - # ``update_from_response`` will correct it after the next API call). - # Skipped when deferring — a deferred estimate is known to over-count - # vs the last real provider prompt, so trusting it for the display - # would re-introduce the very desync we're avoiding. - _last = _compressor.last_prompt_tokens - # Do NOT overwrite the -1 sentinel. compress_context() sets - # last_prompt_tokens=-1 right after compression to mark "no real API - # usage yet". `(x or 0)` evaluates to -1 (truthy) for the sentinel, - # so the old comparison was always True and clobbered the sentinel - # with a schema-inflated rough estimate — re-triggering compression - # on the next turn (#36718). Treat any negative value as "no data". - if _last >= 0 and _preflight_tokens > _last: - _compressor.last_prompt_tokens = _preflight_tokens - - if _preflight_deferred: - logger.info( - "Skipping preflight compression: rough estimate ~%s >= %s, " - "but last real provider prompt was %s after compression", - f"{_preflight_tokens:,}", - f"{_compressor.threshold_tokens:,}", - f"{_compressor.last_real_prompt_tokens:,}", - ) - elif _compressor.should_compress(_preflight_tokens): - logger.info( - "Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)", - f"{_preflight_tokens:,}", - f"{_compressor.threshold_tokens:,}", - agent.model, - f"{_compressor.context_length:,}", - ) - agent._emit_status( - f"📦 Preflight compression: ~{_preflight_tokens:,} tokens " - f">= {_compressor.threshold_tokens:,} threshold. " - "This may take a moment." - ) - # May need multiple passes for very large sessions with small - # context windows (each pass summarises the middle N turns). - for _pass in range(3): - _orig_len = len(messages) - messages, active_system_prompt = agent._compress_context( - messages, system_message, approx_tokens=_preflight_tokens, - task_id=effective_task_id, - ) - if len(messages) >= _orig_len: - break # Cannot compress further - # Compression created a new session — clear the history - # reference so _flush_messages_to_session_db writes ALL - # compressed messages to the new session's SQLite, not - # skipping them because conversation_history is still the - # pre-compression length. - conversation_history = None - # Fix: reset retry counters after compression so the model - # gets a fresh budget on the compressed context. Without - # this, pre-compression retries carry over and the model - # hits "(empty)" immediately after compression-induced - # context loss. - agent._empty_content_retries = 0 - agent._thinking_prefill_retries = 0 - agent._last_content_with_tools = None - agent._last_content_tools_all_housekeeping = False - agent._mute_post_response = False - # Re-estimate after compression - _preflight_tokens = estimate_request_tokens_rough( - messages, - system_prompt=active_system_prompt or "", - tools=agent.tools or None, - ) - if not _compressor.should_compress(_preflight_tokens): - break # Under threshold or anti-thrash guard stopped it - - # Plugin hook: pre_llm_call - # Fired once per turn before the tool-calling loop. Plugins can - # return a dict with a ``context`` key (or a plain string) whose - # value is appended to the current turn's user message. - # - # Context is ALWAYS injected into the user message, never the - # system prompt. This preserves the prompt cache prefix — the - # system prompt stays identical across turns so cached tokens - # are reused. The system prompt is Hermes's territory; plugins - # contribute context alongside the user's input. - # - # All injected context is ephemeral (not persisted to session DB). - _plugin_user_context = "" - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _pre_results = _invoke_hook( - "pre_llm_call", - session_id=agent.session_id, - task_id=effective_task_id, - turn_id=turn_id, - user_message=original_user_message, - conversation_history=list(messages), - is_first_turn=(not bool(conversation_history)), - model=agent.model, - platform=getattr(agent, "platform", None) or "", - sender_id=getattr(agent, "_user_id", None) or "", - ) - _ctx_parts: list[str] = [] - for r in _pre_results: - if isinstance(r, dict) and r.get("context"): - _ctx_parts.append(str(r["context"])) - elif isinstance(r, str) and r.strip(): - _ctx_parts.append(r) - if _ctx_parts: - _plugin_user_context = "\n\n".join(_ctx_parts) - except Exception as exc: - logger.warning("pre_llm_call hook failed: %s", exc) - - # Main conversation loop + # Main conversation loop counters (pure locals consumed by the loop below). api_call_count = 0 final_response = None interrupted = False @@ -770,53 +444,6 @@ def run_conversation( compression_attempts = 0 _turn_exit_reason = "unknown" # Diagnostic: why the loop ended - # Per-turn file-mutation verifier state. Keyed by resolved path; - # each failed ``write_file`` / ``patch`` call records the error - # preview. Later successful writes to the same path remove the - # entry (the model recovered). At end-of-turn, any entries still - # present are surfaced in an advisory footer so the model cannot - # over-claim success while the file is actually unchanged on disk. - agent._turn_failed_file_mutations: Dict[str, Dict[str, Any]] = {} - - # Record the execution thread so interrupt()/clear_interrupt() can - # scope the tool-level interrupt signal to THIS agent's thread only. - # Must be set before any thread-scoped interrupt syncing. - agent._execution_thread_id = threading.current_thread().ident - - # Always clear stale per-thread state from a previous turn. If an - # interrupt arrived before startup finished, preserve it and bind it - # to this execution thread now instead of dropping it on the floor. - _ra()._set_interrupt(False, agent._execution_thread_id) - if agent._interrupt_requested: - _ra()._set_interrupt(True, agent._execution_thread_id) - agent._interrupt_thread_signal_pending = False - else: - agent._interrupt_message = None - agent._interrupt_thread_signal_pending = False - - # Notify memory providers of the new turn so cadence tracking works. - # Must happen BEFORE prefetch_all() so providers know which turn it is - # and can gate context/dialectic refresh via contextCadence/dialecticCadence. - if agent._memory_manager: - try: - _turn_msg = original_user_message if isinstance(original_user_message, str) else "" - agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg) - except Exception: - pass - - # External memory provider: prefetch once before the tool loop. - # Reuse the cached result on every iteration to avoid re-calling - # prefetch_all() on each tool call (10 tool calls = 10x latency + cost). - # Use original_user_message (clean input) — user_message may contain - # injected skill content that bloats / breaks provider queries. - _ext_prefetch_cache = "" - if agent._memory_manager: - try: - _query = original_user_message if isinstance(original_user_message, str) else "" - _ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or "" - except Exception: - pass - # Optional opt-in runtime: if api_mode == codex_app_server, hand the # turn to the codex app-server subprocess (terminal/file ops/patching # all run inside Codex). Default Hermes path is bypassed entirely. @@ -1172,22 +799,8 @@ def run_conversation( api_start_time = time.time() retry_count = 0 max_retries = agent._api_max_retries - primary_recovery_attempted = False + _retry = TurnRetryState() max_compression_attempts = 3 - codex_auth_retry_attempted=False - anthropic_auth_retry_attempted=False - nous_auth_retry_attempted=False - nous_paid_entitlement_refresh_attempted=False - copilot_auth_retry_attempted=False - thinking_sig_retry_attempted = False - invalid_encrypted_content_retry_attempted = False - image_shrink_retry_attempted = False - multimodal_tool_content_retry_attempted = False - oauth_1m_beta_retry_attempted = False - llama_cpp_grammar_retry_attempted = False - has_retried_429 = False - restart_with_compressed_messages = False - restart_with_length_continuation = False finish_reason = "stop" response = None # Guard against UnboundLocalError if all retries fail @@ -1220,7 +833,7 @@ def run_conversation( if agent._try_activate_fallback(): retry_count = 0 compression_attempts = 0 - primary_recovery_attempted = False + _retry.primary_recovery_attempted = False continue # No fallback available — surface buffered context # so user sees the rate-limit message that led here. @@ -1545,7 +1158,7 @@ def run_conversation( if agent._try_activate_fallback(): retry_count = 0 compression_attempts = 0 - primary_recovery_attempted = False + _retry.primary_recovery_attempted = False continue # Check for error field in response (some providers include this) @@ -1616,7 +1229,7 @@ def run_conversation( if agent._try_activate_fallback(): retry_count = 0 compression_attempts = 0 - primary_recovery_attempted = False + _retry.primary_recovery_attempted = False continue # Terminal — flush buffered retry trace so user sees what happened. agent._flush_status_buffer() @@ -1840,7 +1453,7 @@ def run_conversation( } messages.append(continue_msg) agent._session_messages = messages - restart_with_length_continuation = True + _retry.restart_with_length_continuation = True break partial_response = agent._strip_think_blocks("".join(truncated_response_parts)).strip() @@ -2089,7 +1702,7 @@ def run_conversation( f"({hit_pct:.0f}% hit, {written:,} written)" ) - has_retried_429 = False # Reset on success + _retry.has_retried_429 = False # Reset on success # Note: don't clear the retry buffer here — an "API call # success" only means we got bytes back, not that we got # usable content. Empty responses still loop through the @@ -2117,7 +1730,7 @@ def run_conversation( agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True) agent._persist_session(messages, conversation_history) interrupted = True - final_response = f"Operation interrupted: waiting for model response ({api_elapsed:.1f}s elapsed)." + final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)." break except Exception as api_error: @@ -2419,9 +2032,9 @@ def run_conversation( getattr(agent, "provider", "") or "", getattr(agent, "base_url", "") or "", ) - and not nous_paid_entitlement_refresh_attempted + and not _retry.nous_paid_entitlement_refresh_attempted ): - nous_paid_entitlement_refresh_attempted = True + _retry.nous_paid_entitlement_refresh_attempted = True if _try_refresh_nous_paid_entitlement_credentials(agent): agent._vprint( f"{agent.log_prefix}🔐 Nous paid access verified — " @@ -2430,9 +2043,9 @@ def run_conversation( ) continue - recovered_with_pool, has_retried_429 = agent._recover_with_credential_pool( + recovered_with_pool, _retry.has_retried_429 = agent._recover_with_credential_pool( status_code=status_code, - has_retried_429=has_retried_429, + has_retried_429=_retry.has_retried_429, classified_reason=classified.reason, error_context=error_context, ) @@ -2447,9 +2060,9 @@ def run_conversation( # fails, fall through to normal error handling. if ( classified.reason == FailoverReason.image_too_large - and not image_shrink_retry_attempted + and not _retry.image_shrink_retry_attempted ): - image_shrink_retry_attempted = True + _retry.image_shrink_retry_attempted = True if agent._try_shrink_image_parts_in_messages(api_messages): agent._vprint( f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — " @@ -2472,9 +2085,9 @@ def run_conversation( # downgrade, and retry once. See issue #27344. if ( classified.reason == FailoverReason.multimodal_tool_content_unsupported - and not multimodal_tool_content_retry_attempted + and not _retry.multimodal_tool_content_retry_attempted ): - multimodal_tool_content_retry_attempted = True + _retry.multimodal_tool_content_retry_attempted = True if agent._try_strip_image_parts_from_tool_messages(api_messages): agent._vprint( f"{agent.log_prefix}📐 Provider rejected list-type tool content — " @@ -2501,9 +2114,9 @@ def run_conversation( classified.reason == FailoverReason.oauth_long_context_beta_forbidden and agent.api_mode == "anthropic_messages" and agent._is_anthropic_oauth - and not oauth_1m_beta_retry_attempted + and not _retry.oauth_1m_beta_retry_attempted ): - oauth_1m_beta_retry_attempted = True + _retry.oauth_1m_beta_retry_attempted = True if not getattr(agent, "_oauth_1m_beta_disabled", False): agent._oauth_1m_beta_disabled = True try: @@ -2522,9 +2135,9 @@ def run_conversation( agent.api_mode == "codex_responses" and agent.provider in {"openai-codex", "xai-oauth"} and status_code == 401 - and not codex_auth_retry_attempted + and not _retry.codex_auth_retry_attempted ): - codex_auth_retry_attempted = True + _retry.codex_auth_retry_attempted = True if agent._try_refresh_codex_client_credentials(force=True): _label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex" agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...") @@ -2533,9 +2146,9 @@ def run_conversation( agent.api_mode == "chat_completions" and agent.provider == "nous" and status_code == 401 - and not nous_auth_retry_attempted + and not _retry.nous_auth_retry_attempted ): - nous_auth_retry_attempted = True + _retry.nous_auth_retry_attempted = True if agent._try_refresh_nous_client_credentials(force=True): print(f"{agent.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...") continue @@ -2564,9 +2177,9 @@ def run_conversation( if ( agent.provider == "copilot" and status_code == 401 - and not copilot_auth_retry_attempted + and not _retry.copilot_auth_retry_attempted ): - copilot_auth_retry_attempted = True + _retry.copilot_auth_retry_attempted = True if agent._try_refresh_copilot_client_credentials(): agent._buffer_vprint(f"🔐 Copilot credentials refreshed after 401. Retrying request...") continue @@ -2574,9 +2187,9 @@ def run_conversation( agent.api_mode == "anthropic_messages" and status_code == 401 and hasattr(agent, '_anthropic_api_key') - and not anthropic_auth_retry_attempted + and not _retry.anthropic_auth_retry_attempted ): - anthropic_auth_retry_attempted = True + _retry.anthropic_auth_retry_attempted = True from agent.anthropic_adapter import _is_oauth_token from agent.azure_identity_adapter import is_token_provider if agent._try_refresh_anthropic_client_credentials(): @@ -2617,9 +2230,9 @@ def run_conversation( # blocks at all. One-shot — don't retry infinitely. if ( classified.reason == FailoverReason.thinking_signature - and not thinking_sig_retry_attempted + and not _retry.thinking_sig_retry_attempted ): - thinking_sig_retry_attempted = True + _retry.thinking_sig_retry_attempted = True for _m in messages: if isinstance(_m, dict): _m.pop("reasoning_details", None) @@ -2651,7 +2264,7 @@ def run_conversation( # handles it (the provider is rejecting something else). if ( classified.reason == FailoverReason.invalid_encrypted_content - and not invalid_encrypted_content_retry_attempted + and not _retry.invalid_encrypted_content_retry_attempted and agent.api_mode == "codex_responses" and bool(getattr(agent, "_codex_reasoning_replay_enabled", True)) and any( @@ -2662,7 +2275,7 @@ def run_conversation( for _m in messages ) ): - invalid_encrypted_content_retry_attempted = True + _retry.invalid_encrypted_content_retry_attempted = True replay_stats = agent._disable_codex_reasoning_replay(messages) agent._vprint( f"{agent.log_prefix}⚠️ Encrypted reasoning replay was rejected by the provider — " @@ -2689,9 +2302,9 @@ def run_conversation( # fires only for users on llama.cpp's OAI server. if ( classified.reason == FailoverReason.llama_cpp_grammar_pattern - and not llama_cpp_grammar_retry_attempted + and not _retry.llama_cpp_grammar_retry_attempted ): - llama_cpp_grammar_retry_attempted = True + _retry.llama_cpp_grammar_retry_attempted = True try: from tools.schema_sanitizer import strip_pattern_and_format _, _stripped = strip_pattern_and_format(agent.tools) @@ -2902,7 +2515,7 @@ def run_conversation( f"(was {old_ctx:,}), retrying..." ) time.sleep(2) - restart_with_compressed_messages = True + _retry.restart_with_compressed_messages = True break # Fall through to normal error handling if compression # is exhausted or didn't help. @@ -2935,7 +2548,7 @@ def run_conversation( if agent._try_activate_fallback(reason=classified.reason): retry_count = 0 compression_attempts = 0 - primary_recovery_attempted = False + _retry.primary_recovery_attempted = False continue # ── Nous Portal: record rate limit & skip retries ───── @@ -3073,7 +2686,7 @@ def run_conversation( if len(messages) < original_len: agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") time.sleep(2) # Brief pause between compression retries - restart_with_compressed_messages = True + _retry.restart_with_compressed_messages = True break else: # Terminal — surface buffered context so the user @@ -3145,7 +2758,7 @@ def run_conversation( "failed": True, "compression_exhausted": True, } - restart_with_compressed_messages = True + _retry.restart_with_compressed_messages = True break # Error is about the INPUT being too large. Only reduce @@ -3230,7 +2843,7 @@ def run_conversation( if len(messages) < original_len: agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") time.sleep(2) # Brief pause between compression retries - restart_with_compressed_messages = True + _retry.restart_with_compressed_messages = True break else: # Can't compress further and already at minimum tier @@ -3335,7 +2948,7 @@ def run_conversation( if agent._try_activate_fallback(): retry_count = 0 compression_attempts = 0 - primary_recovery_attempted = False + _retry.primary_recovery_attempted = False continue if api_kwargs is not None: agent._dump_api_request_debug( @@ -3467,10 +3080,10 @@ def run_conversation( # client once for transient transport errors (stale # connection pool, TCP reset). Only attempted once # per API call block. - if not primary_recovery_attempted and agent._try_recover_primary_transport( + if not _retry.primary_recovery_attempted and agent._try_recover_primary_transport( api_error, retry_count=retry_count, max_retries=max_retries, ): - primary_recovery_attempted = True + _retry.primary_recovery_attempted = True retry_count = 0 continue # Try fallback before giving up entirely @@ -3479,7 +3092,7 @@ def run_conversation( if agent._try_activate_fallback(): retry_count = 0 compression_attempts = 0 - primary_recovery_attempted = False + _retry.primary_recovery_attempted = False continue # Terminal — flush buffered retry/fallback trace. agent._flush_status_buffer() @@ -3630,17 +3243,17 @@ def run_conversation( _turn_exit_reason = "interrupted_during_api_call" break - if restart_with_compressed_messages: + if _retry.restart_with_compressed_messages: api_call_count -= 1 agent.iteration_budget.refund() # Count compression restarts toward the retry limit to prevent # infinite loops when compression reduces messages but not enough # to fit the context window. retry_count += 1 - restart_with_compressed_messages = False + _retry.restart_with_compressed_messages = False continue - if restart_with_length_continuation: + if _retry.restart_with_length_continuation: # Progressively boost the output token budget on each retry. # Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768. # Applies to all providers via _ephemeral_max_output_tokens. @@ -4583,383 +4196,26 @@ def run_conversation( messages.append({"role": "assistant", "content": final_response}) break - if final_response is None and ( - api_call_count >= agent.max_iterations - or agent.iteration_budget.remaining <= 0 - ): - # Budget exhausted — ask the model for a summary via one extra - # API call with tools stripped. _handle_max_iterations injects a - # user message and makes a single toolless request. - _turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})" - agent._emit_status( - f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) " - "— asking model to summarise" - ) - if not agent.quiet_mode: - agent._safe_print( - f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) " - "— requesting summary..." - ) - final_response = agent._handle_max_iterations(messages, api_call_count) - - # If running as a kanban worker, signal the dispatcher that the - # worker could not complete (rather than treating it as a - # protocol violation). The agent loop strips tools before calling - # _handle_max_iterations, so the model cannot call kanban_block - # itself — we must do it on its behalf. - # - # We route through ``_record_task_failure(outcome="timed_out")`` - # rather than ``kanban_block`` so this counts toward the - # ``consecutive_failures`` counter and the dispatcher's - # ``failure_limit`` circuit breaker (#29747 gap 2). Without this, - # a task whose worker keeps exhausting its budget would block - # silently each run, get auto-promoted by the operator (or never - # surface), and re-block in an endless loop with no signal. - _kanban_task = os.environ.get("HERMES_KANBAN_TASK") - if _kanban_task: - try: - from hermes_cli import kanban_db as _kb - _conn = _kb.connect() - try: - _kb._record_task_failure( - _conn, - _kanban_task, - error=( - f"Iteration budget exhausted " - f"({api_call_count}/{agent.max_iterations}) — " - "task could not complete within the allowed " - "iterations" - ), - outcome="timed_out", - release_claim=True, - end_run=True, - event_payload_extra={ - "budget_used": api_call_count, - "budget_max": agent.max_iterations, - }, - ) - logger.info( - "recorded budget-exhausted failure for task %s (%d/%d)", - _kanban_task, api_call_count, agent.max_iterations, - ) - finally: - try: - _conn.close() - except Exception: - pass - except Exception: - logger.warning( - "Failed to record budget-exhausted failure for task %s", - _kanban_task, - exc_info=True, - ) - - # Determine if conversation completed successfully - completed = ( - final_response is not None - and api_call_count < agent.max_iterations - and not failed - ) - - # Save trajectory if enabled. ``user_message`` may be a multimodal - # list of parts; the trajectory format wants a plain string. - agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed) - - # Clean up VM and browser for this task after conversation completes - agent._cleanup_task_resources(effective_task_id) - - # Persist session to both JSON log and SQLite only after private retry - # scaffolding has been removed. Otherwise a later user "continue" turn - # can replay assistant("(empty)") / recovery nudges and fall into the - # same empty-response loop again. - agent._drop_trailing_empty_response_scaffolding(messages) - agent._persist_session(messages, conversation_history) - - # ── Turn-exit diagnostic log ───────────────────────────────────── - # Always logged at INFO so agent.log captures WHY every turn ended. - # When the last message is a tool result (agent was mid-work), log - # at WARNING — this is the "just stops" scenario users report. - _last_msg_role = messages[-1].get("role") if messages else None - _last_tool_name = None - if _last_msg_role == "tool": - # Walk back to find the assistant message with the tool call - for _m in reversed(messages): - if _m.get("role") == "assistant" and _m.get("tool_calls"): - _tcs = _m["tool_calls"] - if _tcs and isinstance(_tcs[0], dict): - _last_tool_name = _tcs[-1].get("function", {}).get("name") - break - - _turn_tool_count = sum( - 1 for m in messages - if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls") - ) - _resp_len = len(final_response) if final_response else 0 - _budget_used = agent.iteration_budget.used if agent.iteration_budget else 0 - _budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0 - - _diag_msg = ( - "Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d " - "tool_turns=%d last_msg_role=%s response_len=%d session=%s" - ) - _diag_args = ( - _turn_exit_reason, agent.model, api_call_count, agent.max_iterations, - _budget_used, _budget_max, - _turn_tool_count, _last_msg_role, _resp_len, - agent.session_id or "none", - ) - - if _last_msg_role == "tool" and not interrupted: - # Agent was mid-work — this is the "just stops" case. - logger.warning( - "Turn ended with pending tool result (agent may appear stuck). " - + _diag_msg + " last_tool=%s", - *_diag_args, _last_tool_name, - ) - else: - logger.info(_diag_msg, *_diag_args) - - # File-mutation verifier footer. - # If one or more ``write_file`` / ``patch`` calls failed during this - # turn and were never superseded by a successful write to the same - # path, append an advisory footer to the assistant response. This - # catches the specific case — reported by Ben Eng (#15524-adjacent) - # — where a model issues a batch of parallel patches, half of them - # fail with "Could not find old_string", and the model summarises - # the turn claiming every file was edited. The user then has to - # manually run ``git status`` to catch the lie. With this footer - # the truth is surfaced on every turn, so over-claiming is - # structurally impossible past the model. - # - # Gate: only applied when a real text response exists for this - # turn and the user didn't interrupt. Empty/interrupted turns - # already have other surface text that shouldn't be augmented. - if final_response and not interrupted: - try: - _failed = getattr(agent, "_turn_failed_file_mutations", None) or {} - if _failed and agent._file_mutation_verifier_enabled(): - footer = agent._format_file_mutation_failure_footer(_failed) - if footer: - final_response = final_response.rstrip() + "\n\n" + footer - except Exception as _ver_err: - logger.debug("file-mutation verifier footer failed: %s", _ver_err) - - # Turn-completion explainer. - # When a turn ends abnormally after substantive work — empty content - # after retries, a partial/truncated stream, a still-pending tool - # result, or an iteration/budget limit — the user otherwise gets a - # blank or fragmentary response box with no consolidated reason why - # the agent stopped (#34452). Surface a single user-visible - # explanation derived from ``_turn_exit_reason``, mirroring the - # file-mutation verifier footer pattern above. - # - # Gate carefully so healthy turns stay quiet: - # - ``text_response(...)`` exits never produce an explanation - # (handled inside the formatter), so a terse ``Done.`` is silent. - # - We only ACT when there is no genuinely usable reply this turn: - # an empty response, the "(empty)" terminal sentinel, or a - # suspiciously short partial fragment with no terminating - # punctuation (e.g. "The"). A real short answer keeps its text. - if not interrupted: - try: - if agent._turn_completion_explainer_enabled(): - _stripped = (final_response or "").strip() - _is_empty_terminal = _stripped == "" or _stripped == "(empty)" - # A short fragment that is not a normal text_response exit - # and lacks sentence-ending punctuation is treated as a - # truncated partial (the "The" case from #34452). - _is_partial_fragment = ( - not _is_empty_terminal - and not str(_turn_exit_reason).startswith("text_response") - and len(_stripped) <= 24 - and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"} - ) - if _is_empty_terminal or _is_partial_fragment: - _explanation = agent._format_turn_completion_explanation( - _turn_exit_reason - ) - if _explanation: - if _is_empty_terminal: - # Replace the bare "(empty)"/blank sentinel with - # the actionable explanation. - final_response = _explanation - else: - # Keep the partial fragment, append the reason so - # the user sees both what arrived and why it - # stopped. - final_response = ( - _stripped + "\n\n" + _explanation - ) - except Exception as _exp_err: - logger.debug("turn-completion explainer failed: %s", _exp_err) - - _response_transformed = False - - # Plugin hook: transform_llm_output - # Fired once per turn after the tool-calling loop completes. - # Plugins can transform the LLM's output text before it's returned. - # First hook to return a string wins; None/empty return leaves text unchanged. - if final_response and not interrupted: - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _transform_results = _invoke_hook( - "transform_llm_output", - response_text=final_response, - session_id=agent.session_id or "", - model=agent.model, - platform=getattr(agent, "platform", None) or "", - ) - for _hook_result in _transform_results: - if isinstance(_hook_result, str) and _hook_result: - final_response = _hook_result - _response_transformed = True - break # First non-empty string wins - except Exception as exc: - logger.warning("transform_llm_output hook failed: %s", exc) - - # Plugin hook: post_llm_call - # Fired once per turn after the tool-calling loop completes. - # Plugins can use this to persist conversation data (e.g. sync - # to an external memory system). - if final_response and not interrupted: - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook( - "post_llm_call", - session_id=agent.session_id, - task_id=effective_task_id, - turn_id=turn_id, - user_message=original_user_message, - assistant_response=final_response, - conversation_history=list(messages), - model=agent.model, - platform=getattr(agent, "platform", None) or "", - ) - except Exception as exc: - logger.warning("post_llm_call hook failed: %s", exc) - - # Extract reasoning from the CURRENT turn only. Walk backwards - # but stop at the user message that started this turn — anything - # earlier is from a prior turn and must not leak into the reasoning - # box (confusing stale display; #17055). Within the current turn - # we still want the *most recent* non-empty reasoning: many - # providers (Claude thinking, DeepSeek v4, Codex Responses) emit - # reasoning on the tool-call step and leave the final-answer step - # with reasoning=None, so picking only the last assistant would - # silently drop legitimate same-turn reasoning. - last_reasoning = None - for msg in reversed(messages): - if msg.get("role") == "user": - break # turn boundary — don't cross into prior turns - if msg.get("role") == "assistant" and msg.get("reasoning"): - last_reasoning = msg["reasoning"] - break - - # Build result with interrupt info if applicable - result = { - "final_response": final_response, - "last_reasoning": last_reasoning, - "messages": messages, - "api_calls": api_call_count, - "completed": completed, - "turn_exit_reason": _turn_exit_reason, - "failed": failed, - "partial": False, # True only when stopped due to invalid tool calls - "interrupted": interrupted, - "response_transformed": _response_transformed, - "response_previewed": getattr(agent, "_response_was_previewed", False), - "model": agent.model, - "provider": agent.provider, - "base_url": agent.base_url, - "input_tokens": agent.session_input_tokens, - "output_tokens": agent.session_output_tokens, - "cache_read_tokens": agent.session_cache_read_tokens, - "cache_write_tokens": agent.session_cache_write_tokens, - "reasoning_tokens": agent.session_reasoning_tokens, - "prompt_tokens": agent.session_prompt_tokens, - "completion_tokens": agent.session_completion_tokens, - "total_tokens": agent.session_total_tokens, - "last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0, - "estimated_cost_usd": agent.session_estimated_cost_usd, - "cost_status": agent.session_cost_status, - "cost_source": agent.session_cost_source, - "session_id": agent.session_id, - } - if agent._tool_guardrail_halt_decision is not None: - result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata() - # If a /steer landed after the final assistant turn (no more tool - # batches to drain into), hand it back to the caller so it can be - # delivered as the next user turn instead of being silently lost. - _leftover_steer = agent._drain_pending_steer() - if _leftover_steer: - result["pending_steer"] = _leftover_steer - agent._response_was_previewed = False - - # Include interrupt message if one triggered the interrupt - if interrupted and agent._interrupt_message: - result["interrupt_message"] = agent._interrupt_message - - # Clear interrupt state after handling - agent.clear_interrupt() - - # Clear stream callback so it doesn't leak into future calls - agent._stream_callback = None - - # Check skill trigger NOW — based on how many tool iterations THIS turn used. - _should_review_skills = False - if (agent._skill_nudge_interval > 0 - and agent._iters_since_skill >= agent._skill_nudge_interval - and "skill_manage" in agent.valid_tool_names): - _should_review_skills = True - agent._iters_since_skill = 0 - - # External memory provider: sync the completed turn + queue next prefetch. - agent._sync_external_memory_for_turn( - original_user_message=original_user_message, + # Post-loop turn finalization extracted to agent/turn_finalizer.finalize_turn + # (god-file decomposition Phase 1 step 4). Behavior-neutral: the assembled + # result dict is returned exactly as before. + from agent.turn_finalizer import finalize_turn + return finalize_turn( + agent, final_response=final_response, + api_call_count=api_call_count, interrupted=interrupted, + failed=failed, messages=messages, + conversation_history=conversation_history, + effective_task_id=effective_task_id, + turn_id=turn_id, + user_message=user_message, + original_user_message=original_user_message, + _should_review_memory=_should_review_memory, + _turn_exit_reason=_turn_exit_reason, ) - # Background memory/skill review — runs AFTER the response is delivered - # so it never competes with the user's task for model attention. - if final_response and not interrupted and (_should_review_memory or _should_review_skills): - try: - agent._spawn_background_review( - messages_snapshot=list(messages), - review_memory=_should_review_memory, - review_skills=_should_review_skills, - ) - except Exception: - pass # Background review is best-effort - - # Note: Memory provider on_session_end() + shutdown_all() are NOT - # called here — run_conversation() is called once per user message in - # multi-turn sessions. Shutting down after every turn would kill the - # provider before the second message. Actual session-end cleanup is - # handled by the CLI (atexit / /reset) and gateway (session expiry / - # _reset_session). - - # Plugin hook: on_session_end - # Fired at the very end of every run_conversation call. - # Plugins can use this for cleanup, flushing buffers, etc. - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook( - "on_session_end", - session_id=agent.session_id, - task_id=effective_task_id, - turn_id=turn_id, - completed=completed, - interrupted=interrupted, - model=agent.model, - platform=getattr(agent, "platform", None) or "", - ) - except Exception as exc: - logger.warning("on_session_end hook failed: %s", exc) - - return result - __all__ = ["run_conversation"] diff --git a/agent/credential_pool.py b/agent/credential_pool.py index e5b473ec525..04b22c76a68 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -91,6 +91,7 @@ AUTH_TYPE_OAUTH = "oauth" AUTH_TYPE_API_KEY = "api_key" SOURCE_MANUAL = "manual" +SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code" STRATEGY_FILL_FIRST = "fill_first" STRATEGY_ROUND_ROBIN = "round_robin" @@ -374,7 +375,7 @@ def _iter_custom_providers(config: Optional[dict] = None): yield _normalize_custom_pool_name(name), entry -def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]: +def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optional[str] = None) -> Optional[str]: """Look up the custom_providers list in config.yaml and return 'custom:' for a matching base_url. When provider_name is given, prefer matching by name first (solving the case where diff --git a/agent/curator.py b/agent/curator.py index aae8ec0044a..93986da7a75 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -375,6 +375,11 @@ CURATOR_REVIEW_PROMPT = ( "into ~/.hermes/skills/.archive/) is the maximum destructive action. " "Archives are recoverable; deletion is not.\n" "3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n" + "3b. DO NOT archive, delete, consolidate, move, or otherwise modify any " + "skill named in the protected built-ins list (currently: plan). These " + "back load-bearing UX (slash-command entry points referenced in docs and " + "tips) and are filtered out of the candidate list below — never resurrect " + "one as an archive or absorb target.\n" "4. DO NOT use usage counters as a reason to skip consolidation. The " "counters are new and often mostly zero. Judge overlap on CONTENT, " "not on use_count. 'use=0' is not evidence a skill is valuable; it's " diff --git a/agent/image_routing.py b/agent/image_routing.py index 74b29af7cd8..c8b3f6640c6 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -219,6 +219,35 @@ def _supports_vision_override( coerced = _coerce_capability_bool(per_model.get("supports_vision")) if coerced is not None: return coerced + + # 2b. Legacy list-style custom_providers. Entries are dicts with a + # "name" key and a nested "models" dict. Match by provider name (which + # may appear as the raw name or "custom:" at runtime). + custom_providers = cfg.get("custom_providers") + if isinstance(custom_providers, list): + # Build candidate names: the provider value and the config provider + # value, both raw and with "custom:" prefix stripped/added. + candidate_names: set = set() + for p in filter(None, (provider, config_provider)): + candidate_names.add(p) + if p.startswith("custom:"): + candidate_names.add(p[len("custom:"):]) + else: + candidate_names.add(f"custom:{p}") + for entry_raw in custom_providers: + if not isinstance(entry_raw, dict): + continue + entry_name = str(entry_raw.get("name") or "").strip() + if entry_name not in candidate_names: + continue + models_raw = entry_raw.get("models") + models_cfg = models_raw if isinstance(models_raw, dict) else {} + per_model_raw = models_cfg.get(model) + per_model = per_model_raw if isinstance(per_model_raw, dict) else {} + coerced = _coerce_capability_bool(per_model.get("supports_vision")) + if coerced is not None: + return coerced + return None diff --git a/agent/insights.py b/agent/insights.py index 70907b4f3d5..9977010549c 100644 --- a/agent/insights.py +++ b/agent/insights.py @@ -20,23 +20,17 @@ import json import time from collections import Counter, defaultdict from datetime import datetime -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from agent.usage_pricing import ( CanonicalUsage, - DEFAULT_PRICING, estimate_usage_cost, format_duration_compact, has_known_pricing, ) -_DEFAULT_PRICING = DEFAULT_PRICING -def _has_known_pricing(model_name: str, provider: str = None, base_url: str = None) -> bool: - """Check if a model has known pricing (vs unknown/custom endpoint).""" - return has_known_pricing(model_name, provider=provider, base_url=base_url) - def _estimate_cost( session_or_model: Dict[str, Any] | str, @@ -45,8 +39,8 @@ def _estimate_cost( *, cache_read_tokens: int = 0, cache_write_tokens: int = 0, - provider: str = None, - base_url: str = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, ) -> tuple[float, str]: """Estimate the USD cost for a session row or a model/token tuple.""" if isinstance(session_or_model, dict): @@ -77,9 +71,6 @@ def _estimate_cost( return float(result.amount_usd or 0.0), result.status -def _format_duration(seconds: float) -> str: - """Format seconds into a human-readable duration string.""" - return format_duration_compact(seconds) def _bar_chart(values: List[int], max_width: int = 20) -> List[str]: @@ -435,7 +426,7 @@ class InsightsEngine: included_cost_sessions += 1 elif status == "unknown": unknown_cost_sessions += 1 - if _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")): + if has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")): models_with_pricing.add(display) else: models_without_pricing.add(display) @@ -508,7 +499,7 @@ class InsightsEngine: d["tool_calls"] += s.get("tool_call_count") or 0 estimate, status = _estimate_cost(s) d["cost"] += estimate - d["has_pricing"] = _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")) + d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")) d["cost_status"] = status result = [ @@ -679,7 +670,7 @@ class InsightsEngine: top.append({ "label": "Longest session", "session_id": longest["id"][:16], - "value": _format_duration(dur), + "value": format_duration_compact(dur), "date": datetime.fromtimestamp(longest["started_at"]).strftime("%b %d"), }) @@ -764,7 +755,7 @@ class InsightsEngine: lines.append(f" Input tokens: {o['total_input_tokens']:<12,} Output tokens: {o['total_output_tokens']:,}") lines.append(f" Total tokens: {o['total_tokens']:,}") if o["total_hours"] > 0: - lines.append(f" Active time: ~{_format_duration(o['total_hours'] * 3600):<11} Avg session: ~{_format_duration(o['avg_session_duration'])}") + lines.append(f" Active time: ~{format_duration_compact(o['total_hours'] * 3600):<11} Avg session: ~{format_duration_compact(o['avg_session_duration'])}") lines.append(f" Avg msgs/session: {o['avg_messages_per_session']:.1f}") lines.append("") @@ -879,7 +870,7 @@ class InsightsEngine: lines.append(f"**Sessions:** {o['total_sessions']} | **Messages:** {o['total_messages']:,} | **Tool calls:** {o['total_tool_calls']:,}") lines.append(f"**Tokens:** {o['total_tokens']:,} (in: {o['total_input_tokens']:,} / out: {o['total_output_tokens']:,})") if o["total_hours"] > 0: - lines.append(f"**Active time:** ~{_format_duration(o['total_hours'] * 3600)} | **Avg session:** ~{_format_duration(o['avg_session_duration'])}") + lines.append(f"**Active time:** ~{format_duration_compact(o['total_hours'] * 3600)} | **Avg session:** ~{format_duration_compact(o['avg_session_duration'])}") lines.append("") # Models (top 5) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index f0a72d35954..3cb3a734a8f 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -28,6 +28,8 @@ from __future__ import annotations import logging import re import inspect +import threading +from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider @@ -35,6 +37,12 @@ from tools.registry import tool_error logger = logging.getLogger(__name__) +# How long shutdown_all() waits for in-flight background sync/prefetch work +# to drain before abandoning it. A wedged provider must never block process +# teardown indefinitely — the worker threads are daemon, so anything still +# running past this window dies with the interpreter. +_SYNC_DRAIN_TIMEOUT_S = 5.0 + # --------------------------------------------------------------------------- # Context fencing helpers @@ -252,6 +260,13 @@ class MemoryManager: self._providers: List[MemoryProvider] = [] self._tool_to_provider: Dict[str, MemoryProvider] = {} self._has_external: bool = False # True once a non-builtin provider is added + # Background executor for end-of-turn sync/prefetch. Lazily created on + # first use so the common builtin-only path spawns no extra threads. + # A single worker serializes a provider's writes (turn N must land + # before turn N+1) and caps thread growth at one per manager. See + # _submit_background() and the sync_all/queue_prefetch_all rationale. + self._sync_executor: Optional[ThreadPoolExecutor] = None + self._sync_executor_lock = threading.Lock() # -- Registration -------------------------------------------------------- @@ -375,15 +390,27 @@ class MemoryManager: return "\n\n".join(parts) def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None: - """Queue background prefetch on all providers for the next turn.""" - for provider in self._providers: - try: - provider.queue_prefetch(query, session_id=session_id) - except Exception as e: - logger.debug( - "Memory provider '%s' queue_prefetch failed (non-fatal): %s", - provider.name, e, - ) + """Queue background prefetch on all providers for the next turn. + + Provider work is dispatched to a background worker so a slow or + wedged provider can never block the caller. See ``sync_all`` for + the full rationale (agent stuck "running" minutes after a turn). + """ + providers = list(self._providers) + if not providers: + return + + def _run() -> None: + for provider in providers: + try: + provider.queue_prefetch(query, session_id=session_id) + except Exception as e: + logger.debug( + "Memory provider '%s' queue_prefetch failed (non-fatal): %s", + provider.name, e, + ) + + self._submit_background(_run) # -- Sync ---------------------------------------------------------------- @@ -407,27 +434,120 @@ class MemoryManager: session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None, ) -> None: - """Sync a completed turn to all providers.""" - for provider in self._providers: + """Sync a completed turn to all providers. + + Runs on a background worker thread, NOT inline on the + turn-completion path. A provider's ``sync_turn`` may make a + blocking network/daemon call (a misconfigured Hindsight daemon + was observed blocking ~298s before failing); doing that inline + held ``run_conversation`` open long after the user saw their + response, so every interface (CLI, TUI, gateway) kept the agent + marked "running" for minutes and any follow-up message triggered + an aggressive interrupt. Dispatching off-thread means a slow or + broken provider can never stall the turn — the sync simply + completes (or fails, logged) in the background. + + Writes are serialized through a single worker so turn N lands + before turn N+1; provider implementations don't need their own + ordering guarantees. + """ + providers = list(self._providers) + if not providers: + return + + def _run() -> None: + for provider in providers: + try: + if messages is not None and self._provider_sync_accepts_messages(provider): + provider.sync_turn( + user_content, + assistant_content, + session_id=session_id, + messages=messages, + ) + else: + provider.sync_turn( + user_content, + assistant_content, + session_id=session_id, + ) + except Exception as e: + logger.warning( + "Memory provider '%s' sync_turn failed: %s", + provider.name, e, + ) + + self._submit_background(_run) + + # -- Background dispatch ------------------------------------------------- + + def _submit_background(self, fn) -> None: + """Run ``fn`` on the manager's background worker. + + The executor is created lazily and shared across calls. If the + executor can't be created or has already been shut down, ``fn`` + runs inline as a last-resort fallback — losing the async benefit + but never losing the write itself. ``fn`` must do its own + per-provider error handling; this wrapper only guards executor + plumbing. + """ + executor = self._get_sync_executor() + if executor is None: + # Executor unavailable (shut down / creation failed) — run + # inline rather than drop the work. Slow, but correct. try: - if messages is not None and self._provider_sync_accepts_messages(provider): - provider.sync_turn( - user_content, - assistant_content, - session_id=session_id, - messages=messages, + fn() + except Exception as e: # pragma: no cover - fn guards internally + logger.debug("Inline memory background task failed: %s", e) + return + try: + executor.submit(fn) + except RuntimeError: + # Executor was shut down between the get and the submit + # (teardown race). Fall back to inline. + try: + fn() + except Exception as e: # pragma: no cover - fn guards internally + logger.debug("Inline memory background task failed: %s", e) + + def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]: + """Lazily create the single-worker background executor.""" + if self._sync_executor is not None: + return self._sync_executor + with self._sync_executor_lock: + if self._sync_executor is None: + try: + self._sync_executor = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="mem-sync", ) - else: - provider.sync_turn( - user_content, - assistant_content, - session_id=session_id, - ) - except Exception as e: - logger.warning( - "Memory provider '%s' sync_turn failed: %s", - provider.name, e, - ) + except Exception as e: # pragma: no cover - resource exhaustion + logger.warning("Failed to create memory sync executor: %s", e) + return None + return self._sync_executor + + def flush_pending(self, timeout: Optional[float] = None) -> bool: + """Block until queued sync/prefetch work has drained. + + Single-worker executor means submitting a sentinel and waiting on + it guarantees every previously-submitted task has run. Returns + True if the barrier completed within ``timeout`` (or no executor + exists), False on timeout. Used at real session boundaries and by + tests that need to assert provider state deterministically. + """ + executor = self._sync_executor + if executor is None: + return True + try: + fut = executor.submit(lambda: None) + except RuntimeError: + # Executor already shut down — nothing pending. + return True + try: + fut.result(timeout=timeout) + return True + except Exception: + return False # -- Tools --------------------------------------------------------------- @@ -653,7 +773,15 @@ class MemoryManager: ) def shutdown_all(self) -> None: - """Shut down all providers (reverse order for clean teardown).""" + """Shut down all providers (reverse order for clean teardown). + + Drains the background sync/prefetch executor first (bounded by + ``_SYNC_DRAIN_TIMEOUT_S``) so a turn's final sync has a chance to + land before providers are torn down. The worker threads are + daemon, so anything still wedged past the drain window dies with + the interpreter rather than blocking exit. + """ + self._drain_sync_executor() for provider in reversed(self._providers): try: provider.shutdown() @@ -663,6 +791,52 @@ class MemoryManager: provider.name, e, ) + def _drain_sync_executor(self) -> None: + """Shut down the background executor, waiting briefly for drain. + + Bounded by ``_SYNC_DRAIN_TIMEOUT_S``: a wedged provider must never + hang process/session teardown. We stop accepting new work and + cancel anything still queued, then wait at most the drain timeout + for the currently-running task on a watcher thread. The worker is + daemon, so an over-running task dies with the interpreter. + """ + with self._sync_executor_lock: + executor = self._sync_executor + self._sync_executor = None + if executor is None: + return + try: + # Stop accepting new work and drop anything still queued, but + # do NOT block here — cancel_futures cancels not-yet-started + # tasks; the in-flight one keeps running on its daemon thread. + executor.shutdown(wait=False, cancel_futures=True) + except TypeError: + # Older Python without cancel_futures kwarg. + try: + executor.shutdown(wait=False) + except Exception as e: # pragma: no cover + logger.debug("Memory sync executor shutdown failed: %s", e) + return + except Exception as e: # pragma: no cover + logger.debug("Memory sync executor shutdown failed: %s", e) + return + # Give an in-flight sync a bounded chance to finish on a watcher + # thread so we don't block the caller past the drain timeout. + drainer = threading.Thread( + target=lambda: self._bounded_executor_wait(executor), + daemon=True, + name="mem-sync-drain", + ) + drainer.start() + drainer.join(timeout=_SYNC_DRAIN_TIMEOUT_S) + + @staticmethod + def _bounded_executor_wait(executor: ThreadPoolExecutor) -> None: + try: + executor.shutdown(wait=True) + except Exception as e: # pragma: no cover + logger.debug("Memory sync executor drain wait failed: %s", e) + def initialize_all(self, session_id: str, **kwargs) -> None: """Initialize all providers. diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 1080256e0ac..531e9ae8459 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1684,6 +1684,26 @@ def get_model_context_length( "in config.yaml to override.", model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}", ) + # 3b. Before falling back to the hard 256K default, consult the + # hardcoded catalog as a last resort. A proxied/custom Anthropic + # gateway (e.g. corporate proxy) fails the Ollama/local probes + # above, but the model name may still match an entry in + # DEFAULT_CONTEXT_LENGTHS (e.g. "claude-opus-4-8" → 1M). + # Without this, the early return here short-circuits the catalog + # lookup at step 8 and silently caps context at 256K. + model_lower = model.lower() + for default_model, length in sorted( + DEFAULT_CONTEXT_LENGTHS.items(), + key=lambda x: len(x[0]), + reverse=True, + ): + if default_model in model_lower: + logger.info( + "Using hardcoded context length %s for model %r " + "(custom endpoint, catalog match on %r)", + f"{length:,}", model, default_model, + ) + return length return DEFAULT_FALLBACK_CONTEXT # 4. Anthropic /v1/models API (only for regular API keys, not OAuth) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f908aedb806..36cbad4b886 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe if agent._should_emit_quiet_tool_messages(): cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result) agent._safe_print(f" {cute_msg}") - elif not agent.quiet_mode: + elif getattr(agent, "tool_progress_mode", "all") != "off": _preview_str = _multimodal_text_summary(function_result) if agent.verbose_logging: print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") diff --git a/agent/turn_context.py b/agent/turn_context.py new file mode 100644 index 00000000000..e94d43279ab --- /dev/null +++ b/agent/turn_context.py @@ -0,0 +1,388 @@ +"""Per-turn setup for ``run_conversation`` (the turn prologue). + +``run_conversation`` opened with ~470 lines of straight-line setup before the +tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter +resets, user-message sanitization, todo/nudge-counter hydration, system-prompt +restore-or-build, crash-resilience persistence, preflight context compression, the +``pre_llm_call`` plugin hook, and external-memory prefetch. + +All of that is *prologue* — it runs once per turn, has no back-references into the +loop, and produces a fixed set of values the loop then consumes. ``TurnContext`` +captures those produced values; ``build_turn_context`` performs the setup work and +returns one. ``run_conversation`` is left to unpack the context and run the loop, +shrinking the orchestrator by the full prologue. + +The builder still mutates ``agent`` heavily (counters, thread id, cached prompt, +session DB) exactly as the inline code did — those side effects are the point. The +``TurnContext`` it returns carries only the *locals* the loop reads back. + +Behavior is identical to the original inline prologue; this is a pure +move-and-name refactor with no semantic change. +""" + +from __future__ import annotations + +import logging +import threading +import uuid +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from agent.iteration_budget import IterationBudget +from agent.model_metadata import estimate_request_tokens_rough + +logger = logging.getLogger(__name__) + + +@dataclass +class TurnContext: + """Values produced by the turn prologue and consumed by the turn loop.""" + + # Sanitized inbound message (surrogates stripped). + user_message: str + # Clean message preserved for transcripts / memory queries (no nudge injection). + original_user_message: Any + # Working message list for this turn (loop appends to it). + messages: List[Dict[str, Any]] + # May be reset to None by preflight compression (new session created). + conversation_history: Optional[List[Dict[str, Any]]] + # Cached system prompt active for this turn (may be rebuilt by compression). + active_system_prompt: Optional[str] + # Task / turn identifiers. + effective_task_id: str + turn_id: str + # Index of the current user turn within ``messages``. + current_turn_user_idx: int + # Whether the post-turn memory review should fire. + should_review_memory: bool = False + # Context contributed by ``pre_llm_call`` plugins (appended to user message). + plugin_user_context: str = "" + # External-memory prefetch result, reused across loop iterations. + ext_prefetch_cache: str = "" + + +def build_turn_context( + agent, + user_message: str, + system_message: Optional[str], + conversation_history: Optional[List[Dict[str, Any]]], + task_id: Optional[str], + stream_callback, + persist_user_message: Optional[str], + *, + restore_or_build_system_prompt, + install_safe_stdio, + sanitize_surrogates, + summarize_user_message_for_log, + set_session_context, + set_current_write_origin, + ra, +) -> TurnContext: + """Run the once-per-turn setup and return the loop's input context. + + The callables/helpers the original prologue referenced from the + ``conversation_loop`` module are passed in explicitly to keep this module + free of an import cycle with ``agent.conversation_loop``. + """ + # Guard stdio against OSError from broken pipes (systemd/headless/daemon). + install_safe_stdio() + + agent._ensure_db_session() + + # Tell auxiliary_client what the live main provider/model are for this turn. + try: + from agent.auxiliary_client import set_runtime_main + set_runtime_main( + getattr(agent, "provider", "") or "", + getattr(agent, "model", "") or "", + base_url=getattr(agent, "base_url", "") or "", + api_key=getattr(agent, "api_key", "") or "", + api_mode=getattr(agent, "api_mode", "") or "", + ) + except Exception: + pass + + # Tag log records on this thread with the session ID for ``hermes logs``. + set_session_context(agent.session_id) + + # Bind the skill write-origin ContextVar for this thread. + set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool")) + + # Restore the primary runtime if the previous turn activated fallback. + agent._restore_primary_runtime() + + # Sanitize surrogate characters from user input. + if isinstance(user_message, str): + user_message = sanitize_surrogates(user_message) + if isinstance(persist_user_message, str): + persist_user_message = sanitize_surrogates(persist_user_message) + + # Store stream callback for _interruptible_api_call to pick up. + agent._stream_callback = stream_callback + agent._persist_user_message_idx = None + agent._persist_user_message_override = persist_user_message + # Generate unique task_id if not provided to isolate VMs between tasks. + effective_task_id = task_id or str(uuid.uuid4()) + agent._current_task_id = effective_task_id + turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}" + agent._current_turn_id = turn_id + agent._current_api_request_id = "" + + # Reset retry counters and iteration budget at the start of each turn. + agent._invalid_tool_retries = 0 + agent._invalid_json_retries = 0 + agent._empty_content_retries = 0 + agent._incomplete_scratchpad_retries = 0 + agent._codex_incomplete_retries = 0 + agent._thinking_prefill_retries = 0 + agent._post_tool_empty_retried = False + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + agent._mute_post_response = False + agent._unicode_sanitization_passes = 0 + agent._tool_guardrails.reset_for_turn() + agent._tool_guardrail_halt_decision = None + agent._vision_supported = True + + # Pre-turn connection health check: clean up dead TCP connections. + if agent.api_mode != "anthropic_messages": + try: + if agent._cleanup_dead_connections(): + agent._emit_status( + "🔌 Detected stale connections from a previous provider " + "issue — cleaned up automatically. Proceeding with fresh " + "connection." + ) + except Exception: + pass + # Replay compression warning through status_callback for gateway platforms. + if agent._compression_warning: + agent._replay_compression_warning() + agent._compression_warning = None # send once + + # NOTE: _turns_since_memory and _iters_since_skill are NOT reset here. + agent.iteration_budget = IterationBudget(agent.max_iterations) + + # Log conversation turn start for debugging/observability. + _preview_text = summarize_user_message_for_log(user_message) + _msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text + _msg_preview = _msg_preview.replace("\n", " ") + logger.info( + "conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r", + agent.session_id or "none", agent.model, agent.provider or "unknown", + agent.platform or "unknown", len(conversation_history or []), + _msg_preview, + ) + + # Initialize conversation (copy to avoid mutating the caller's list). + messages = list(conversation_history) if conversation_history else [] + + # Hydrate todo store from conversation history. + if conversation_history and not agent._todo_store.has_items(): + agent._hydrate_todo_store(conversation_history) + + # Hydrate per-session nudge counters from persisted history (issue #22357). + if conversation_history and agent._user_turn_count == 0: + prior_user_turns = sum( + 1 for m in conversation_history if m.get("role") == "user" + ) + if prior_user_turns > 0: + agent._user_turn_count = prior_user_turns + if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0: + agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval + + # Track user turns for memory flush and periodic nudge logic. + agent._user_turn_count += 1 + + # Reset the streaming context scrubber at the top of each turn. + scrubber = getattr(agent, "_stream_context_scrubber", None) + if scrubber is not None: + scrubber.reset() + # Reset the think scrubber for the same reason. + think_scrubber = getattr(agent, "_stream_think_scrubber", None) + if think_scrubber is not None: + think_scrubber.reset() + + # Preserve the original user message (no nudge injection). + original_user_message = persist_user_message if persist_user_message is not None else user_message + + # Track memory nudge trigger (turn-based, checked here). + should_review_memory = False + if (agent._memory_nudge_interval > 0 + and "memory" in agent.valid_tool_names + and agent._memory_store): + agent._turns_since_memory += 1 + if agent._turns_since_memory >= agent._memory_nudge_interval: + should_review_memory = True + agent._turns_since_memory = 0 + + # Add user message. + user_msg = {"role": "user", "content": user_message} + messages.append(user_msg) + current_turn_user_idx = len(messages) - 1 + agent._persist_user_message_idx = current_turn_user_idx + + if not agent.quiet_mode: + _print_preview = summarize_user_message_for_log(user_message) + agent._safe_print( + f"💬 Starting conversation: '{_print_preview[:60]}" + f"{'...' if len(_print_preview) > 60 else ''}'" + ) + + # ── System prompt (cached per session for prefix caching) ── + if agent._cached_system_prompt is None: + restore_or_build_system_prompt(agent, system_message, conversation_history) + + active_system_prompt = agent._cached_system_prompt + + # Crash-resilience: persist the inbound user turn as soon as the session row exists. + try: + agent._persist_session(messages, conversation_history) + except Exception: + logger.warning( + "Early turn-start session persistence failed for session=%s", + agent.session_id or "none", + exc_info=True, + ) + + # ── Preflight context compression ── + if ( + agent.compression_enabled + and len(messages) > agent.context_compressor.protect_first_n + + agent.context_compressor.protect_last_n + 1 + ): + _preflight_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=agent.tools or None, + ) + _compressor = agent.context_compressor + _defer_preflight = getattr( + _compressor, + "should_defer_preflight_to_real_usage", + lambda _tokens: False, + ) + _preflight_deferred = _defer_preflight(_preflight_tokens) + + if not _preflight_deferred: + _last = _compressor.last_prompt_tokens + # Do NOT overwrite the -1 sentinel (#36718). + if _last >= 0 and _preflight_tokens > _last: + _compressor.last_prompt_tokens = _preflight_tokens + + if _preflight_deferred: + logger.info( + "Skipping preflight compression: rough estimate ~%s >= %s, " + "but last real provider prompt was %s after compression", + f"{_preflight_tokens:,}", + f"{_compressor.threshold_tokens:,}", + f"{_compressor.last_real_prompt_tokens:,}", + ) + elif _compressor.should_compress(_preflight_tokens): + logger.info( + "Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)", + f"{_preflight_tokens:,}", + f"{_compressor.threshold_tokens:,}", + agent.model, + f"{_compressor.context_length:,}", + ) + agent._emit_status( + f"📦 Preflight compression: ~{_preflight_tokens:,} tokens " + f">= {_compressor.threshold_tokens:,} threshold. " + "This may take a moment." + ) + for _pass in range(3): + _orig_len = len(messages) + messages, active_system_prompt = agent._compress_context( + messages, system_message, approx_tokens=_preflight_tokens, + task_id=effective_task_id, + ) + if len(messages) >= _orig_len: + break # Cannot compress further + conversation_history = None + agent._empty_content_retries = 0 + agent._thinking_prefill_retries = 0 + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + agent._mute_post_response = False + _preflight_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=agent.tools or None, + ) + if not _compressor.should_compress(_preflight_tokens): + break + + # Plugin hook: pre_llm_call (context injected into user message, not system prompt). + plugin_user_context = "" + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _pre_results = _invoke_hook( + "pre_llm_call", + session_id=agent.session_id, + task_id=effective_task_id, + turn_id=turn_id, + user_message=original_user_message, + conversation_history=list(messages), + is_first_turn=(not bool(conversation_history)), + model=agent.model, + platform=getattr(agent, "platform", None) or "", + sender_id=getattr(agent, "_user_id", None) or "", + ) + _ctx_parts: list[str] = [] + for r in _pre_results: + if isinstance(r, dict) and r.get("context"): + _ctx_parts.append(str(r["context"])) + elif isinstance(r, str) and r.strip(): + _ctx_parts.append(r) + if _ctx_parts: + plugin_user_context = "\n\n".join(_ctx_parts) + except Exception as exc: + logger.warning("pre_llm_call hook failed: %s", exc) + + # Per-turn file-mutation verifier state. + agent._turn_failed_file_mutations = {} + + # Record the execution thread so interrupt()/clear_interrupt() can scope + # the tool-level interrupt signal to THIS agent's thread only. + agent._execution_thread_id = threading.current_thread().ident + + # Clear stale per-thread interrupt state, preserving a pending interrupt. + ra()._set_interrupt(False, agent._execution_thread_id) + if agent._interrupt_requested: + ra()._set_interrupt(True, agent._execution_thread_id) + agent._interrupt_thread_signal_pending = False + else: + agent._interrupt_message = None + agent._interrupt_thread_signal_pending = False + + # Notify memory providers of the new turn (BEFORE prefetch_all). + if agent._memory_manager: + try: + _turn_msg = original_user_message if isinstance(original_user_message, str) else "" + agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg) + except Exception: + pass + + # External memory provider: prefetch once before the tool loop. + ext_prefetch_cache = "" + if agent._memory_manager: + try: + _query = original_user_message if isinstance(original_user_message, str) else "" + ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or "" + except Exception: + pass + + return TurnContext( + user_message=user_message, + original_user_message=original_user_message, + messages=messages, + conversation_history=conversation_history, + active_system_prompt=active_system_prompt, + effective_task_id=effective_task_id, + turn_id=turn_id, + current_turn_user_idx=current_turn_user_idx, + should_review_memory=should_review_memory, + plugin_user_context=plugin_user_context, + ext_prefetch_cache=ext_prefetch_cache, + ) diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py new file mode 100644 index 00000000000..20db3fcef9f --- /dev/null +++ b/agent/turn_finalizer.py @@ -0,0 +1,428 @@ +"""Post-loop turn finalization for ``run_conversation``. + +Extracted from ``agent/conversation_loop.py`` as part of the god-file +decomposition campaign (``~/.hermes/plans/god-file-decomposition.md``, Phase 1 +step 4 — the post-loop ``TurnFinalizer`` seam). ``run_conversation``'s tail +(everything after the main tool-calling ``while`` loop) is lifted here verbatim: +budget-exhaustion summary, trajectory save, session persist, turn diagnostics, +response transforms, result-dict assembly, steer drain, and the memory/skill +review trigger. + +Behavior-neutral: the body is moved unchanged. All ``agent.*`` side effects fire +exactly as before; only the post-loop *locals* are passed in as keyword args, and +the assembled ``result`` dict is returned to ``run_conversation`` which returns it +to the caller. The function is synchronous with a single return — mirroring the +region it replaces (no awaits, no early returns). + +Module ``logger`` is imported lazily inside the body (``from +agent.conversation_loop import logger``) so this module never imports +``agent.conversation_loop`` at import time -> no import cycle, and the log records +keep the exact logger name (``"agent.conversation_loop"``). +""" + +from __future__ import annotations + +import os + +from agent.codex_responses_adapter import _summarize_user_message_for_log + + +def finalize_turn( + agent, + *, + final_response, + api_call_count, + interrupted, + failed, + messages, + conversation_history, + effective_task_id, + turn_id, + user_message, + original_user_message, + _should_review_memory, + _turn_exit_reason, +): + """Run the post-loop finalization and return the turn ``result`` dict. + + Lifted verbatim from ``run_conversation`` (the region after the main agent + loop). See module docstring. + """ + from agent.conversation_loop import logger + + if final_response is None and ( + api_call_count >= agent.max_iterations + or agent.iteration_budget.remaining <= 0 + ): + # Budget exhausted — ask the model for a summary via one extra + # API call with tools stripped. _handle_max_iterations injects a + # user message and makes a single toolless request. + _turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})" + agent._emit_status( + f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) " + "— asking model to summarise" + ) + if not agent.quiet_mode: + agent._safe_print( + f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) " + "— requesting summary..." + ) + final_response = agent._handle_max_iterations(messages, api_call_count) + + # If running as a kanban worker, signal the dispatcher that the + # worker could not complete (rather than treating it as a + # protocol violation). The agent loop strips tools before calling + # _handle_max_iterations, so the model cannot call kanban_block + # itself — we must do it on its behalf. + # + # We route through ``_record_task_failure(outcome="timed_out")`` + # rather than ``kanban_block`` so this counts toward the + # ``consecutive_failures`` counter and the dispatcher's + # ``failure_limit`` circuit breaker (#29747 gap 2). Without this, + # a task whose worker keeps exhausting its budget would block + # silently each run, get auto-promoted by the operator (or never + # surface), and re-block in an endless loop with no signal. + _kanban_task = os.environ.get("HERMES_KANBAN_TASK") + if _kanban_task: + try: + from hermes_cli import kanban_db as _kb + _conn = _kb.connect() + try: + _kb._record_task_failure( + _conn, + _kanban_task, + error=( + f"Iteration budget exhausted " + f"({api_call_count}/{agent.max_iterations}) — " + "task could not complete within the allowed " + "iterations" + ), + outcome="timed_out", + release_claim=True, + end_run=True, + event_payload_extra={ + "budget_used": api_call_count, + "budget_max": agent.max_iterations, + }, + ) + logger.info( + "recorded budget-exhausted failure for task %s (%d/%d)", + _kanban_task, api_call_count, agent.max_iterations, + ) + finally: + try: + _conn.close() + except Exception: + pass + except Exception: + logger.warning( + "Failed to record budget-exhausted failure for task %s", + _kanban_task, + exc_info=True, + ) + + # Determine if conversation completed successfully + completed = ( + final_response is not None + and api_call_count < agent.max_iterations + and not failed + ) + + # Save trajectory if enabled. ``user_message`` may be a multimodal + # list of parts; the trajectory format wants a plain string. + agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed) + + # Clean up VM and browser for this task after conversation completes + agent._cleanup_task_resources(effective_task_id) + + # Persist session to both JSON log and SQLite only after private retry + # scaffolding has been removed. Otherwise a later user "continue" turn + # can replay assistant("(empty)") / recovery nudges and fall into the + # same empty-response loop again. + agent._drop_trailing_empty_response_scaffolding(messages) + agent._persist_session(messages, conversation_history) + + # ── Turn-exit diagnostic log ───────────────────────────────────── + # Always logged at INFO so agent.log captures WHY every turn ended. + # When the last message is a tool result (agent was mid-work), log + # at WARNING — this is the "just stops" scenario users report. + _last_msg_role = messages[-1].get("role") if messages else None + _last_tool_name = None + if _last_msg_role == "tool": + # Walk back to find the assistant message with the tool call + for _m in reversed(messages): + if _m.get("role") == "assistant" and _m.get("tool_calls"): + _tcs = _m["tool_calls"] + if _tcs and isinstance(_tcs[0], dict): + _last_tool_name = _tcs[-1].get("function", {}).get("name") + break + + _turn_tool_count = sum( + 1 for m in messages + if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls") + ) + _resp_len = len(final_response) if final_response else 0 + _budget_used = agent.iteration_budget.used if agent.iteration_budget else 0 + _budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0 + + _diag_msg = ( + "Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d " + "tool_turns=%d last_msg_role=%s response_len=%d session=%s" + ) + _diag_args = ( + _turn_exit_reason, agent.model, api_call_count, agent.max_iterations, + _budget_used, _budget_max, + _turn_tool_count, _last_msg_role, _resp_len, + agent.session_id or "none", + ) + + if _last_msg_role == "tool" and not interrupted: + # Agent was mid-work — this is the "just stops" case. + logger.warning( + "Turn ended with pending tool result (agent may appear stuck). " + + _diag_msg + " last_tool=%s", + *_diag_args, _last_tool_name, + ) + else: + logger.info(_diag_msg, *_diag_args) + + # File-mutation verifier footer. + # If one or more ``write_file`` / ``patch`` calls failed during this + # turn and were never superseded by a successful write to the same + # path, append an advisory footer to the assistant response. This + # catches the specific case — reported by Ben Eng (#15524-adjacent) + # — where a model issues a batch of parallel patches, half of them + # fail with "Could not find old_string", and the model summarises + # the turn claiming every file was edited. The user then has to + # manually run ``git status`` to catch the lie. With this footer + # the truth is surfaced on every turn, so over-claiming is + # structurally impossible past the model. + # + # Gate: only applied when a real text response exists for this + # turn and the user didn't interrupt. Empty/interrupted turns + # already have other surface text that shouldn't be augmented. + if final_response and not interrupted: + try: + _failed = getattr(agent, "_turn_failed_file_mutations", None) or {} + if _failed and agent._file_mutation_verifier_enabled(): + footer = agent._format_file_mutation_failure_footer(_failed) + if footer: + final_response = final_response.rstrip() + "\n\n" + footer + except Exception as _ver_err: + logger.debug("file-mutation verifier footer failed: %s", _ver_err) + + # Turn-completion explainer. + # When a turn ends abnormally after substantive work — empty content + # after retries, a partial/truncated stream, a still-pending tool + # result, or an iteration/budget limit — the user otherwise gets a + # blank or fragmentary response box with no consolidated reason why + # the agent stopped (#34452). Surface a single user-visible + # explanation derived from ``_turn_exit_reason``, mirroring the + # file-mutation verifier footer pattern above. + # + # Gate carefully so healthy turns stay quiet: + # - ``text_response(...)`` exits never produce an explanation + # (handled inside the formatter), so a terse ``Done.`` is silent. + # - We only ACT when there is no genuinely usable reply this turn: + # an empty response, the "(empty)" terminal sentinel, or a + # suspiciously short partial fragment with no terminating + # punctuation (e.g. "The"). A real short answer keeps its text. + if not interrupted: + try: + if agent._turn_completion_explainer_enabled(): + _stripped = (final_response or "").strip() + _is_empty_terminal = _stripped == "" or _stripped == "(empty)" + # A short fragment that is not a normal text_response exit + # and lacks sentence-ending punctuation is treated as a + # truncated partial (the "The" case from #34452). + _is_partial_fragment = ( + not _is_empty_terminal + and not str(_turn_exit_reason).startswith("text_response") + and len(_stripped) <= 24 + and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"} + ) + if _is_empty_terminal or _is_partial_fragment: + _explanation = agent._format_turn_completion_explanation( + _turn_exit_reason + ) + if _explanation: + if _is_empty_terminal: + # Replace the bare "(empty)"/blank sentinel with + # the actionable explanation. + final_response = _explanation + else: + # Keep the partial fragment, append the reason so + # the user sees both what arrived and why it + # stopped. + final_response = ( + _stripped + "\n\n" + _explanation + ) + except Exception as _exp_err: + logger.debug("turn-completion explainer failed: %s", _exp_err) + + _response_transformed = False + + # Plugin hook: transform_llm_output + # Fired once per turn after the tool-calling loop completes. + # Plugins can transform the LLM's output text before it's returned. + # First hook to return a string wins; None/empty return leaves text unchanged. + if final_response and not interrupted: + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _transform_results = _invoke_hook( + "transform_llm_output", + response_text=final_response, + session_id=agent.session_id or "", + model=agent.model, + platform=getattr(agent, "platform", None) or "", + ) + for _hook_result in _transform_results: + if isinstance(_hook_result, str) and _hook_result: + final_response = _hook_result + _response_transformed = True + break # First non-empty string wins + except Exception as exc: + logger.warning("transform_llm_output hook failed: %s", exc) + + # Plugin hook: post_llm_call + # Fired once per turn after the tool-calling loop completes. + # Plugins can use this to persist conversation data (e.g. sync + # to an external memory system). + if final_response and not interrupted: + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "post_llm_call", + session_id=agent.session_id, + task_id=effective_task_id, + turn_id=turn_id, + user_message=original_user_message, + assistant_response=final_response, + conversation_history=list(messages), + model=agent.model, + platform=getattr(agent, "platform", None) or "", + ) + except Exception as exc: + logger.warning("post_llm_call hook failed: %s", exc) + + # Extract reasoning from the CURRENT turn only. Walk backwards + # but stop at the user message that started this turn — anything + # earlier is from a prior turn and must not leak into the reasoning + # box (confusing stale display; #17055). Within the current turn + # we still want the *most recent* non-empty reasoning: many + # providers (Claude thinking, DeepSeek v4, Codex Responses) emit + # reasoning on the tool-call step and leave the final-answer step + # with reasoning=None, so picking only the last assistant would + # silently drop legitimate same-turn reasoning. + last_reasoning = None + for msg in reversed(messages): + if msg.get("role") == "user": + break # turn boundary — don't cross into prior turns + if msg.get("role") == "assistant" and msg.get("reasoning"): + last_reasoning = msg["reasoning"] + break + + # Build result with interrupt info if applicable + result = { + "final_response": final_response, + "last_reasoning": last_reasoning, + "messages": messages, + "api_calls": api_call_count, + "completed": completed, + "turn_exit_reason": _turn_exit_reason, + "failed": failed, + "partial": False, # True only when stopped due to invalid tool calls + "interrupted": interrupted, + "response_transformed": _response_transformed, + "response_previewed": getattr(agent, "_response_was_previewed", False), + "model": agent.model, + "provider": agent.provider, + "base_url": agent.base_url, + "input_tokens": agent.session_input_tokens, + "output_tokens": agent.session_output_tokens, + "cache_read_tokens": agent.session_cache_read_tokens, + "cache_write_tokens": agent.session_cache_write_tokens, + "reasoning_tokens": agent.session_reasoning_tokens, + "prompt_tokens": agent.session_prompt_tokens, + "completion_tokens": agent.session_completion_tokens, + "total_tokens": agent.session_total_tokens, + "last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0, + "estimated_cost_usd": agent.session_estimated_cost_usd, + "cost_status": agent.session_cost_status, + "cost_source": agent.session_cost_source, + "session_id": agent.session_id, + } + if agent._tool_guardrail_halt_decision is not None: + result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata() + # If a /steer landed after the final assistant turn (no more tool + # batches to drain into), hand it back to the caller so it can be + # delivered as the next user turn instead of being silently lost. + _leftover_steer = agent._drain_pending_steer() + if _leftover_steer: + result["pending_steer"] = _leftover_steer + agent._response_was_previewed = False + + # Include interrupt message if one triggered the interrupt + if interrupted and agent._interrupt_message: + result["interrupt_message"] = agent._interrupt_message + + # Clear interrupt state after handling + agent.clear_interrupt() + + # Clear stream callback so it doesn't leak into future calls + agent._stream_callback = None + + # Check skill trigger NOW — based on how many tool iterations THIS turn used. + _should_review_skills = False + if (agent._skill_nudge_interval > 0 + and agent._iters_since_skill >= agent._skill_nudge_interval + and "skill_manage" in agent.valid_tool_names): + _should_review_skills = True + agent._iters_since_skill = 0 + + # External memory provider: sync the completed turn + queue next prefetch. + agent._sync_external_memory_for_turn( + original_user_message=original_user_message, + final_response=final_response, + interrupted=interrupted, + messages=messages, + ) + + # Background memory/skill review — runs AFTER the response is delivered + # so it never competes with the user's task for model attention. + if final_response and not interrupted and (_should_review_memory or _should_review_skills): + try: + agent._spawn_background_review( + messages_snapshot=list(messages), + review_memory=_should_review_memory, + review_skills=_should_review_skills, + ) + except Exception: + pass # Background review is best-effort + + # Note: Memory provider on_session_end() + shutdown_all() are NOT + # called here — run_conversation() is called once per user message in + # multi-turn sessions. Shutting down after every turn would kill the + # provider before the second message. Actual session-end cleanup is + # handled by the CLI (atexit / /reset) and gateway (session expiry / + # _reset_session). + + # Plugin hook: on_session_end + # Fired at the very end of every run_conversation call. + # Plugins can use this for cleanup, flushing buffers, etc. + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "on_session_end", + session_id=agent.session_id, + task_id=effective_task_id, + turn_id=turn_id, + completed=completed, + interrupted=interrupted, + model=agent.model, + platform=getattr(agent, "platform", None) or "", + ) + except Exception as exc: + logger.warning("on_session_end hook failed: %s", exc) + + return result diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py new file mode 100644 index 00000000000..188fe3f1c16 --- /dev/null +++ b/agent/turn_retry_state.py @@ -0,0 +1,68 @@ +"""Per-attempt recovery bookkeeping for the conversation turn loop. + +The inner retry loop in ``run_conversation`` (``while retry_count < +max_retries``) makes several distinct recovery attempts on a single model API +call: a credential-pool 429 retry, a per-provider OAuth refresh (codex, +anthropic, nous, copilot), a long-context compression restart, a length- +continuation restart, and a handful of format-recovery branches (thinking- +signature stripping, multimodal-tool-content stripping, llama.cpp grammar +fallback, image shrink, invalid-encrypted-content, 1M-beta header). + +Each of those branches is guarded by a one-shot boolean so it fires at most +once per attempt. They used to be ~16 bare ``*_attempted`` / ``has_retried_*`` +/ ``restart_with_*`` locals declared inline before the loop and threaded +through its 2,400-line body. ``TurnRetryState`` collapses them into one object +the loop mutates in place (``state.codex_auth_retry_attempted = True``), giving +the recovery bookkeeping a single named, testable home. + +Loop-control variables (``retry_count``, ``max_retries``, +``max_compression_attempts``) intentionally stay as plain locals — they are the +``while`` mechanics, not recovery bookkeeping, and putting them on the object +would add indirection without clarifying anything. + +This module is dependency-free so it can be unit-tested in isolation and +imported by the turn loop without an import cycle. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields + + +@dataclass +class TurnRetryState: + """One-shot recovery guards + restart signals for a single API-call attempt. + + A fresh instance is created for each iteration of the outer turn loop + (once per ``api_call_count``). Each guard fires its recovery branch at most + once; the ``restart_with_*`` signals are read by the loop after the attempt + to decide whether to rebuild the request and retry. + """ + + # ── Per-provider OAuth / credential refresh guards ─────────────────── + codex_auth_retry_attempted: bool = False + anthropic_auth_retry_attempted: bool = False + nous_auth_retry_attempted: bool = False + nous_paid_entitlement_refresh_attempted: bool = False + copilot_auth_retry_attempted: bool = False + + # ── Format / payload recovery guards ───────────────────────────────── + thinking_sig_retry_attempted: bool = False + invalid_encrypted_content_retry_attempted: bool = False + image_shrink_retry_attempted: bool = False + multimodal_tool_content_retry_attempted: bool = False + oauth_1m_beta_retry_attempted: bool = False + llama_cpp_grammar_retry_attempted: bool = False + + # ── Transport / rate-limit recovery ────────────────────────────────── + primary_recovery_attempted: bool = False + has_retried_429: bool = False + + # ── Restart signals (read by the outer loop after the attempt) ─────── + restart_with_compressed_messages: bool = False + restart_with_length_continuation: bool = False + + def __iter__(self): + # Convenience for debugging / tests: iterate (name, value) pairs. + for f in fields(self): + yield f.name, getattr(self, f.name) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d874d7991d9..c28aea0bb1b 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1902,12 +1902,36 @@ function resolveWebDist() { const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') if (directoryExists(unpackedDist)) return unpackedDist - return path.join(APP_ROOT, 'dist') + // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives + // INSIDE app.asar — not a servable filesystem directory — so the embedded + // dashboard backend 404s on static routes (see #41327, #39472). The durable + // fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2 + // unpackedDist above resolves). If we still land here while packaged, log it + // so the cause isn't silent. + const fallback = path.join(APP_ROOT, 'dist') + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { + rememberLog( + `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + + `is not a real directory: ${fallback}. Static routes will 404. ` + + `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` + ) + } + return fallback } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] - return candidates.find(fileExists) || candidates[0] + const found = candidates.find(fileExists) + if (found) return found + // Nothing on disk. A packaged build with no renderer bundle blank-pages with + // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause + // and the fix before Electron loads the missing file. + rememberLog( + `[renderer] index.html not found — the desktop app was packaged without a ` + + `renderer bundle. Tried: ${candidates.join(', ')}. ` + + `Rebuild with: hermes desktop --force-build` + ) + return candidates[0] } function resolveHermesCwd() { @@ -3137,7 +3161,7 @@ function buildApplicationMenu() { label: 'Actual Size', accelerator: 'CommandOrControl+0', click: () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) + setAndPersistZoomLevel(mainWindow, 0) } }, { @@ -3145,8 +3169,7 @@ function buildApplicationMenu() { accelerator: 'CommandOrControl+Plus', click: () => { if (mainWindow && !mainWindow.isDestroyed()) { - const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9) - mainWindow.webContents.setZoomLevel(next) + setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1) } } }, @@ -3155,8 +3178,7 @@ function buildApplicationMenu() { accelerator: 'CommandOrControl+-', click: () => { if (mainWindow && !mainWindow.isDestroyed()) { - const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9) - mainWindow.webContents.setZoomLevel(next) + setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1) } } }, @@ -3218,6 +3240,38 @@ function installPreviewShortcut(window) { }) } +// Zoom level is persisted in the renderer's own localStorage (per-origin, +// survives reloads/restarts) rather than a main-process JSON file. The main +// process owns setZoomLevel, so we mirror each change into localStorage and +// read it back on did-finish-load to re-apply after reloads or crash recovery. +const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' + +function clampZoomLevel(value) { + if (!Number.isFinite(value)) return 0 + return Math.min(Math.max(value, -9), 9) +} + +function setAndPersistZoomLevel(window, zoomLevel) { + if (!window || window.isDestroyed()) return + const next = clampZoomLevel(zoomLevel) + window.webContents.setZoomLevel(next) + window.webContents + .executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`) + .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) +} + +function restorePersistedZoomLevel(window) { + if (!window || window.isDestroyed()) return + window.webContents + .executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`) + .then(stored => { + if (stored == null || !window || window.isDestroyed()) return + const level = clampZoomLevel(Number(stored)) + window.webContents.setZoomLevel(level) + }) + .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) +} + function installZoomShortcuts(window) { // Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2). // The menu items handle this on macOS (where the menu is always present), @@ -3231,15 +3285,13 @@ function installZoomShortcuts(window) { const key = input.key if (key === '0') { event.preventDefault() - window.webContents.setZoomLevel(0) + setAndPersistZoomLevel(window, 0) } else if (key === '=' || key === '+') { event.preventDefault() - const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9) - window.webContents.setZoomLevel(next) + setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP) } else if (key === '-') { event.preventDefault() - const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9) - window.webContents.setZoomLevel(next) + setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP) } }) } @@ -3847,10 +3899,12 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const scoped = key ? config.profiles?.[key] || null : null const block = key ? scoped || {} : config.remote || {} + const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) + const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) - const remoteUrl = String(block.url || '') - const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' + const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') + const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' let remoteOauthConnected = false if (authMode === 'oauth' && remoteUrl) { @@ -3876,7 +3930,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon remoteTokenSet: Boolean(remoteToken), // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. - envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) + envOverride } } @@ -4614,7 +4668,7 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1220, height: 800, - minWidth: 900, + minWidth: 400, minHeight: 620, title: 'Hermes', // Frameless title bar on every platform so the renderer can paint the @@ -4730,6 +4784,7 @@ function createWindow() { } mainWindow.webContents.once('did-finish-load', () => { + restorePersistedZoomLevel(mainWindow) broadcastBootProgress() sendWindowStateChanged() startHermes().catch(error => rememberLog(error.stack || error.message)) @@ -4737,6 +4792,45 @@ function createWindow() { } ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +// Reconnect-after-wake recovery. A REMOTE primary backend has no child process, +// so the 'exit'/'error' handlers that would clear a dead connectionPromise never +// fire — once the remote becomes unreachable across a sleep/wake the renderer +// re-dials the same dead descriptor forever and the composer stays stuck on +// "Starting Hermes…". Before the renderer's backoff loop reconnects, it asks us +// to confirm the cached PRIMARY backend is still reachable; if a remote one is +// not, we drop the cache so the next getConnection() rebuilds it. Local backends +// self-heal via their child 'exit' handler, so we never touch them here. +ipcMain.handle('hermes:connection:revalidate', async () => { + if (!connectionPromise) { + return { ok: true, rebuilt: false } + } + + let conn = null + try { + conn = await connectionPromise + } catch { + // The cached boot already rejected (its own catch nulls connectionPromise); + // nothing to revalidate — the next getConnection() builds fresh. + return { ok: true, rebuilt: false } + } + + if (!conn || conn.mode !== 'remote' || !conn.baseUrl) { + return { ok: true, rebuilt: false } + } + + const base = conn.baseUrl.replace(/\/+$/, '') + try { + await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) + return { ok: true, rebuilt: false } + } catch { + // Unreachable remote: drop the stale cache so the renderer's next reconnect + // tick rebuilds a fresh, reachable descriptor. resetHermesConnection only + // nulls connectionPromise for a remote (no child to SIGTERM). + rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') + resetHermesConnection() + return { ok: true, rebuilt: true } + } +}) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) return { ok: true } diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 27bc1b20b53..cf094e751c3 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron') contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), + revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 33aaf057ec8..22f7a9dd4b6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build", + "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", @@ -166,7 +166,8 @@ "afterSign": "scripts/notarize.cjs", "asarUnpack": [ "**/*.node", - "**/prebuilds/**" + "**/prebuilds/**", + "dist/**" ], "mac": { "category": "public.app-category.developer-tools", diff --git a/apps/desktop/pr-assets/session-source-folders.png b/apps/desktop/pr-assets/session-source-folders.png new file mode 100644 index 00000000000..b8d8a969b79 Binary files /dev/null and b/apps/desktop/pr-assets/session-source-folders.png differ diff --git a/apps/desktop/scripts/assert-dist-built.cjs b/apps/desktop/scripts/assert-dist-built.cjs new file mode 100644 index 00000000000..8eea50f45a3 --- /dev/null +++ b/apps/desktop/scripts/assert-dist-built.cjs @@ -0,0 +1,70 @@ +"use strict" + +// Build-time guard: refuse to hand a half-built renderer to electron-builder. +// +// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`. +// If the `build` step (tsc -b && vite build) fails but packaging proceeds +// anyway — a stale checkout that fails typecheck, an interrupted vite build, +// or npm not short-circuiting `&&` in some shells — electron-builder happily +// packages an app with an empty or missing `dist/`. The result launches but +// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why. +// +// This runs at the tail of `build`, after vite build, so any packaging path +// inherits it. It fails loud and early instead of shipping a broken bundle. +// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). + +const fs = require("fs") +const path = require("path") + +// Pure check — returns { ok: true } or { ok: false, error: "..." }. +// Kept side-effect-free so it can be unit tested without spawning a process. +function checkDistBuilt(distDir) { + if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { + return { ok: false, error: `no dist directory at ${distDir}` } + } + + const indexHtml = path.join(distDir, "index.html") + if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { + return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } + } + if (fs.statSync(indexHtml).size === 0) { + return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } + } + + // index.html alone isn't enough — vite emits hashed JS into dist/assets. + // An index.html with no script bundle still blank-pages. + const assetsDir = path.join(distDir, "assets") + const hasAssets = + fs.existsSync(assetsDir) && + fs.statSync(assetsDir).isDirectory() && + fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) + if (!hasAssets) { + return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } + } + + return { ok: true } +} + +function main() { + const desktopRoot = path.resolve(__dirname, "..") + const distDir = path.join(desktopRoot, "dist") + const result = checkDistBuilt(distDir) + + if (!result.ok) { + console.error(`\n✗ assert-dist-built: ${result.error}`) + console.error(" The renderer bundle is missing or incomplete, so packaging") + console.error(" would produce an app that launches to a blank page.") + console.error(" Re-run the build and check the tsc/vite output above for the") + console.error(" real failure, then package again:") + console.error(` cd ${desktopRoot} && npm run build\n`) + process.exit(1) + } + + console.log("✓ assert-dist-built: dist/index.html + assets present") +} + +if (require.main === module) { + main() +} + +module.exports = { checkDistBuilt } diff --git a/apps/desktop/scripts/assert-dist-built.test.cjs b/apps/desktop/scripts/assert-dist-built.test.cjs new file mode 100644 index 00000000000..5121762469a --- /dev/null +++ b/apps/desktop/scripts/assert-dist-built.test.cjs @@ -0,0 +1,84 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') + +function makeDist(extra) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) + const distDir = path.join(tempRoot, 'dist') + fs.mkdirSync(distDir, { recursive: true }) + if (extra) extra(distDir) + return { tempRoot, distDir } +} + +test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.writeFileSync(path.join(d, 'index.html'), '
', 'utf8') + fs.mkdirSync(path.join(d, 'assets')) + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8') + }) + try { + assert.deepEqual(checkDistBuilt(distDir), { ok: true }) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when the dist directory is absent', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) + try { + const result = checkDistBuilt(path.join(tempRoot, 'dist')) + assert.equal(result.ok, false) + assert.match(result.error, /no dist directory/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when index.html is missing', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.mkdirSync(path.join(d, 'assets')) + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8') + }) + try { + const result = checkDistBuilt(distDir) + assert.equal(result.ok, false) + assert.match(result.error, /index\.html is missing/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when index.html is empty', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8') + fs.mkdirSync(path.join(d, 'assets')) + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8') + }) + try { + const result = checkDistBuilt(distDir) + assert.equal(result.ok, false) + assert.match(result.error, /index\.html is empty/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when assets/ has no JS bundle', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8') + fs.mkdirSync(path.join(d, 'assets')) + // CSS only, no JS — still a blank page at runtime. + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8') + }) + try { + const result = checkDistBuilt(distDir) + assert.equal(result.ok, false) + assert.match(result.error, /no built JS bundle/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 572e1360a2c..4a0d3829c39 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -124,7 +124,10 @@ function ChatHeader({ return (
-
+
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ef1832837f3..99f7f881372 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -19,6 +19,7 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' @@ -39,6 +40,7 @@ import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/he import { useI18n } from '@/i18n' import { profileColor } from '@/lib/profile-color' import { sessionMatchesSearch } from '@/lib/session-search' +import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' import { $cronJobs } from '@/store/cron' import { @@ -47,8 +49,11 @@ import { $sidebarAgentsGrouped, $sidebarCronOpen, $sidebarOpen, + $sidebarOverlayMounted, $sidebarPinsOpen, $sidebarRecentsOpen, + $sidebarSessionOrderIds, + $sidebarWorkspaceOrderIds, pinSession, reorderPinnedSession, SESSION_SEARCH_FOCUS_EVENT, @@ -56,6 +61,8 @@ import { setSidebarCronOpen, setSidebarPinsOpen, setSidebarRecentsOpen, + setSidebarSessionOrderIds, + setSidebarWorkspaceOrderIds, SIDEBAR_SESSIONS_PAGE_SIZE, unpinSession } from '@/store/layout' @@ -116,10 +123,14 @@ const WORKSPACE_PAGE = 5 // ALL-profiles view: show only the latest N per profile up front to keep the // unified list scannable, then reveal/fetch more in N-sized steps on demand. const PROFILE_INITIAL_PAGE = 5 -const WS_ID_PREFIX = 'workspace:' +const GROUP_DND_ID_PREFIX = 'group:' +const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui']) + +const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}` + +const parseGroupDndId = (id: string) => + id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null -const wsId = (id: string) => `${WS_ID_PREFIX}${id}` -const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null) const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded)) const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0 @@ -150,6 +161,33 @@ function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[ return out } +function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { + if (!currentIds.length) { + return [] + } + + if (!orderIds.length) { + return currentIds + } + + const current = new Set(currentIds) + const next = orderIds.filter(id => current.has(id)) + const known = new Set(next) + + for (const id of currentIds) { + if (!known.has(id)) { + next.push(id) + known.add(id) + } + } + + return next +} + +function sameIds(left: string[], right: string[]) { + return left.length === right.length && left.every((item, index) => item === right[index]) +} + const baseName = (path: string) => path .replace(/[/\\]+$/, '') @@ -183,7 +221,11 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { } } -function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] { +function workspaceGroupsFor( + sessions: SessionInfo[], + noWorkspaceLabel: string, + options: { preserveSessionOrder?: boolean } = {} +): SidebarSessionGroup[] { const groups = new Map() for (const session of sessions) { @@ -196,17 +238,56 @@ function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): groups.set(id, group) } - // Groups keep recency order (Map insertion = first-seen in the recency-sorted - // input, so an active project floats up), but rows *within* a group sort by - // creation time so they don't reshuffle every time a message lands — keeps - // muscle memory intact. - for (const group of groups.values()) { - group.sessions.sort((a, b) => b.started_at - a.started_at) + if (!options.preserveSessionOrder) { + // Groups keep recency order (Map insertion = first-seen in the recency-sorted + // input, so an active project floats up), but rows *within* a group sort by + // creation time so they don't reshuffle every time a message lands — keeps + // muscle memory intact. + for (const group of groups.values()) { + group.sessions.sort((a, b) => b.started_at - a.started_at) + } } return [...groups.values()] } +function sourceSessionGroupsFor(sessions: SessionInfo[]): { + localSessions: SessionInfo[] + sourceGroups: SidebarSessionGroup[] +} { + const groups = new Map() + const localSessions: SessionInfo[] = [] + + for (const session of sessions) { + const sourceId = normalizeSessionSource(session.source) + + if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) { + localSessions.push(session) + + continue + } + + const label = sessionSourceLabel(sourceId) ?? sourceId + + const group = groups.get(sourceId) ?? { + id: `source:${sourceId}`, + label, + mode: 'source', + path: null, + sessions: [], + sourceId + } + + group.sessions.push(session) + groups.set(sourceId, group) + } + + return { + localSessions, + sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) + } +} + function useSortableBindings(id: string) { const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) @@ -215,7 +296,11 @@ function useSortableBindings(id: string) { dragHandleProps: { ...attributes, ...listeners }, ref: setNodeRef, reorderable: true as const, - style: { transform: CSS.Transform.toString(transform), transition } + style: { + transform: CSS.Transform.toString(transform), + transition: isDragging ? undefined : transition, + willChange: isDragging ? 'transform' : undefined + } } } @@ -247,6 +332,9 @@ export function ChatSidebar({ const { t } = useI18n() const s = t.sidebar const sidebarOpen = useStore($sidebarOpen) + // Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail. + const overlayMounted = useStore($sidebarOverlayMounted) + const contentVisible = sidebarOpen || overlayMounted const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) const pinnedSessionIds = useStore($pinnedSessionIds) @@ -270,8 +358,8 @@ export function ChatSidebar({ // profile while scope is still ALL (persisted), the rail is hidden and they'd // otherwise be stuck in the grouped view with no way out. const showAllProfiles = multiProfile && profileScope === ALL_PROFILES - const [agentOrderIds, setAgentOrderIds] = useState([]) - const [workspaceOrderIds, setWorkspaceOrderIds] = useState([]) + const agentOrderIds = useStore($sidebarSessionOrderIds) + const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds) const [searchQuery, setSearchQuery] = useState('') const [serverMatches, setServerMatches] = useState([]) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) @@ -425,14 +513,40 @@ export function ChatSidebar({ [sortedSessions, pinnedRealIdSet] ) + useEffect(() => { + const next = reconcileOrderIds( + unpinnedAgentSessions.map(s => s.id), + agentOrderIds + ) + + if (!sameIds(next, agentOrderIds)) { + setSidebarSessionOrderIds(next) + } + }, [agentOrderIds, unpinnedAgentSessions]) + const agentSessions = useMemo( () => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds), [unpinnedAgentSessions, agentOrderIds] ) + const { localSessions: localAgentSessions, sourceGroups } = useMemo( + () => sourceSessionGroupsFor(agentSessions), + [agentSessions] + ) + + const orderedSourceGroups = useMemo( + () => orderByIds(sourceGroups, g => g.id, workspaceOrderIds), + [sourceGroups, workspaceOrderIds] + ) + const agentGroups = useMemo( - () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), - [agentSessions, s.noWorkspace, workspaceOrderIds] + () => + orderByIds( + workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }), + g => g.id, + workspaceOrderIds + ), + [localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds] ) const loadMoreForProfileGroup = useCallback( @@ -445,9 +559,7 @@ export function ChatSidebar({ void Promise.resolve(onLoadMoreProfileSessions(profile)) .catch(() => undefined) - .finally(() => - setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest) - ) + .finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)) }, [onLoadMoreProfileSessions] ) @@ -478,15 +590,17 @@ export function ChatSidebar({ groups.set(key, group) } - return [...groups.values()] - .map(group => ({ - ...group, - loadingMore: Boolean(profileLoadMorePending[group.id]), - onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined, - totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0) - })) - // default (root) first, then the rest alphabetically. - .sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label))) + return ( + [...groups.values()] + .map(group => ({ + ...group, + loadingMore: Boolean(profileLoadMorePending[group.id]), + onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined, + totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0) + })) + // default (root) first, then the rest alphabetically. + .sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label))) + ) }, [ showAllProfiles, agentSessions, @@ -496,6 +610,53 @@ export function ChatSidebar({ sessionProfileTotals ]) + const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions + + const displayAgentGroups = useMemo(() => { + if (orderedSourceGroups.length) { + const localGroups = agentsGrouped + ? agentGroups + : localAgentSessions.length + ? [ + { + id: 'local-sessions', + label: 'Local', + mode: 'workspace' as const, + path: null, + sessions: localAgentSessions + } + ] + : [] + + return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds) + } + + return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined + }, [ + agentGroups, + agentsGrouped, + localAgentSessions, + orderedSourceGroups, + profileGroups, + showAllProfiles, + workspaceOrderIds + ]) + + useEffect(() => { + if (!displayAgentGroups?.length || showAllProfiles) { + return + } + + const next = reconcileOrderIds( + displayAgentGroups.map(g => g.id), + workspaceOrderIds + ) + + if (!sameIds(next, workspaceOrderIds)) { + setSidebarWorkspaceOrderIds(next) + } + }, [displayAgentGroups, showAllProfiles, workspaceOrderIds]) + const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 @@ -543,23 +704,24 @@ export function ChatSidebar({ const activeId = String(active.id) const overId = String(over.id) - const activeWs = parseWsId(activeId) - const overWs = parseWsId(overId) + const activeGroup = parseGroupDndId(activeId) + const overGroup = parseGroupDndId(overId) - if (activeWs && overWs) { - const oldIdx = agentGroups.findIndex(g => g.id === activeWs) - const newIdx = agentGroups.findIndex(g => g.id === overWs) + if (activeGroup && overGroup) { + const groups = displayAgentGroups ?? [] + const oldIdx = groups.findIndex(g => g.id === activeGroup) + const newIdx = groups.findIndex(g => g.id === overGroup) if (oldIdx < 0 || newIdx < 0) { return } - setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id)) + setSidebarWorkspaceOrderIds(arrayMove(groups, oldIdx, newIdx).map(g => g.id)) return } - if (activeWs || overWs) { + if (activeGroup || overGroup) { return } @@ -570,7 +732,7 @@ export function ChatSidebar({ return } - setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id)) + setSidebarSessionOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id)) } return ( @@ -580,7 +742,11 @@ export function ChatSidebar({ panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0', sidebarOpen ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' - : 'pointer-events-none border-transparent bg-transparent opacity-0' + : 'pointer-events-none border-transparent bg-transparent opacity-0', + // While floated by PaneShell's hover-reveal, force visible + interactive + // — on hover (group-hover/reveal) or when keyboard-pinned (data-forced). + 'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100', + 'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100' )} collapsible="none" > @@ -624,14 +790,14 @@ export function ChatSidebar({ type="button" > - {sidebarOpen && ( + {contentVisible && ( <> - + {s.nav[item.id] ?? item.label} {isNewSession && ( )} @@ -645,7 +811,7 @@ export function ChatSidebar({ - {sidebarOpen && showSessionSections && ( + {contentVisible && showSessionSections && (
)} - {sidebarOpen && showSessionSections && trimmedQuery && ( + {contentVisible && showSessionSections && trimmedQuery && ( )} - {sidebarOpen && showSessionSections && !trimmedQuery && ( + {contentVisible && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && showSessionSections && !trimmedQuery && ( + {contentVisible && showSessionSections && !trimmedQuery && ( - {!showAllProfiles && agentSessions.length > 0 ? ( + {!showAllProfiles && localAgentSessions.length > 0 ? ( - ) - })} -
+ key={theme.name} + onClick={() => { + triggerHaptic('crisp') + setTheme(theme.name) + }} + type="button" + > + +
+
+
+ {theme.label} +
+
+ {theme.description} +
+
+ {active && ( + + + + )} +
+ + ) + })} +
+ {showProfileNote && ( +

+ {a.themeProfileNote(activeProfileName)} +

+ )} + } description={a.themeDesc} title={a.themeTitle} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index af9c75d6b7d..1c60e6411cf 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react' import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' import { SidebarProvider } from '@/components/ui/sidebar' +import { useMediaQuery } from '@/hooks/use-media-query' import { $fileBrowserOpen, $panesFlipped, @@ -16,6 +17,8 @@ import { import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' + import { KeybindPanel } from './keybind-panel' import { StatusbarControls, type StatusbarItem } from './statusbar-controls' import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' @@ -58,6 +61,7 @@ export function AppShell({ const sidebarOpen = useStore($sidebarOpen) const fileBrowserOpen = useStore($fileBrowserOpen) const panesFlipped = useStore($panesFlipped) + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) const connection = useStore($connection) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) @@ -71,8 +75,10 @@ export function AppShell({ // The inset clears the top-left titlebar buttons when nothing covers the // window's left edge. Default layout: the sessions sidebar sits there. - // Flipped layout: the file browser does instead. - const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen + // Flipped layout: the file browser does instead. Below the collapse + // breakpoint both rails are force-collapsed (hover-reveal overlay), so the + // edge is uncovered regardless of their stored open state. + const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen) const titlebarContentInset = leftEdgePaneOpen ? 0 diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index c700cb51019..c471d0f517a 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' +import { useI18n } from '@/i18n' import { Activity, AlertCircle, @@ -16,17 +17,17 @@ import { Zap, ZapFilled } from '@/lib/icons' -import { useI18n } from '@/i18n' import { formatModelStatusLabel } from '@/lib/model-status-label' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' -import { setSessionYolo } from '@/lib/yolo-session' +import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session' import { $desktopActionTasks } from '@/store/activity' import { $previewServerRestartStatus } from '@/store/preview' import { $activeSessionId, $busy, + $connection, $currentFastMode, $currentModel, $currentProvider, @@ -40,11 +41,18 @@ import { setYoloActive } from '@/store/session' import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' -import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates' +import { + $backendUpdateApply, + $backendUpdateStatus, + $desktopVersion, + $updateApply, + $updateStatus, + openUpdateOverlayFor +} from '@/store/updates' import type { StatusResponse } from '@/types/hermes' import { CRON_ROUTE } from '../../routes' -import type { StatusbarItem } from '../statusbar-controls' +import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls' interface StatusbarItemsOptions { agentsOpen: boolean @@ -97,7 +105,10 @@ export function useStatusbarItems({ const subagentsBySession = useStore($subagentsBySession) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) + const backendUpdateStatus = useStore($backendUpdateStatus) + const backendUpdateApply = useStore($backendUpdateApply) const desktopVersion = useStore($desktopVersion) + const connection = useStore($connection) const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) @@ -105,22 +116,39 @@ export function useStatusbarItems({ // Per-session approval bypass (same scope as the TUI's Shift+Tab). On a // new-chat draft (no runtime session yet) we arm locally; the session-create // path applies it once the backend session exists. - const toggleYolo = useCallback(async () => { - const next = !$yoloActive.get() - const sid = $activeSessionId.get() + // + // Shift+click flips the GLOBAL approvals.mode instead — a persistent, + // all-sessions/CLI/TUI/cron bypass that survives restarts. + const toggleYolo = useCallback( + async (modifiers?: StatusbarSelectModifiers) => { + const next = !$yoloActive.get() - setYoloActive(next) + setYoloActive(next) - if (!sid) { - return - } + if (modifiers?.shiftKey) { + try { + await setGlobalYolo(requestGateway, next) + } catch { + setYoloActive(!next) + } - try { - await setSessionYolo(requestGateway, sid, next) - } catch { - setYoloActive(!next) - } - }, [requestGateway]) + return + } + + const sid = $activeSessionId.get() + + if (!sid) { + return + } + + try { + await setSessionYolo(requestGateway, sid, next) + } catch { + setYoloActive(!next) + } + }, + [requestGateway] + ) const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady) @@ -177,18 +205,19 @@ export function useStatusbarItems({ ? 'text-amber-600 hover:text-amber-600' : 'text-destructive hover:text-destructive' - const versionItem = useMemo(() => { + const clientVersionItem = useMemo(() => { const appVersion = desktopVersion?.appVersion const sha = updateStatus?.currentSha?.slice(0, 7) ?? null const behind = updateStatus?.behind ?? 0 const applying = updateApply.applying || updateApply.stage === 'restart' - const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown) + const remote = connection?.mode === 'remote' + + const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown) + const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version const behindHint = !applying && behind > 0 ? ` (+${behind})` : '' const label = applying - ? updateApply.stage === 'restart' - ? `${base} · ${copy.restart}` - : `${base} · ${copy.update}` + ? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}` : `${base}${behindHint}` const tooltip = [ @@ -203,17 +232,18 @@ export function useStatusbarItems({ return { className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined, - detail: appVersion && sha && !applying ? sha : undefined, + detail: appVersion && sha && !applying && !remote ? sha : undefined, hidden: !appVersion && !sha, icon: applying ? : , - id: 'version', + id: 'version-client', label, - onSelect: () => setUpdateOverlayOpen(true), + onSelect: () => openUpdateOverlayFor('client'), title: tooltip || undefined, variant: 'action' } }, [ desktopVersion?.appVersion, + connection?.mode, copy, updateApply.applying, updateApply.message, @@ -223,6 +253,50 @@ export function useStatusbarItems({ updateStatus?.currentSha ]) + const backendVersionItem = useMemo(() => { + if (connection?.mode !== 'remote') { + return null + } + + const backendVersion = statusSnapshot?.version + const behind = backendUpdateStatus?.behind ?? 0 + const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart' + + const base = copy.backendLabel(backendVersion ?? copy.unknown) + const behindHint = !applying && behind > 0 ? ` (+${behind})` : '' + + const label = applying + ? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}` + : `${base}${behindHint}` + + const tooltip = [ + applying ? backendUpdateApply.message || copy.updateInProgress : null, + !applying && behind > 0 && copy.commitsBehind(behind, 'main'), + backendVersion && copy.backendVersion(backendVersion) + ] + .filter(Boolean) + .join(' · ') + + return { + className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined, + hidden: !backendVersion, + icon: applying ? : , + id: 'version-backend', + label, + onSelect: () => openUpdateOverlayFor('backend'), + title: tooltip || undefined, + variant: 'action' + } + }, [ + connection?.mode, + statusSnapshot?.version, + backendUpdateStatus?.behind, + backendUpdateApply.applying, + backendUpdateApply.message, + backendUpdateApply.stage, + copy + ]) + const coreLeftStatusbarItems = useMemo( () => [ { @@ -333,7 +407,7 @@ export function useStatusbarItems({ ), id: 'yolo', - onSelect: () => void toggleYolo(), + onSelect: modifiers => void toggleYolo(modifiers), title: yoloActive ? copy.yoloOn : copy.yoloOff, variant: 'action' }, @@ -368,7 +442,8 @@ export function useStatusbarItems({ variant: 'action' as const }) }, - versionItem + clientVersionItem, + ...(backendVersionItem ? [backendVersionItem] : []) ], [ busy, @@ -384,7 +459,8 @@ export function useStatusbarItems({ showYoloToggle, toggleYolo, turnStartedAt, - versionItem, + clientVersionItem, + backendVersionItem, yoloActive ] ) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index d66761d0b82..538d2acf522 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -24,6 +24,7 @@ import { $visibleModels, collapseModelFamilies, DEFAULT_VISIBLE_PER_PROVIDER, + effectiveVisibleKeys, type ModelFamily, modelVisibilityKey, setModelVisibilityOpen @@ -86,13 +87,17 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model : null const providers = modelOptions.data?.providers + const effectiveVisibleModels = useMemo( + () => effectiveVisibleKeys(visibleModels, providers ?? []), + [visibleModels, providers] + ) const switchTo = (model: string, provider: string) => onSelectModel({ model, persistGlobal: !activeSessionId, provider }) const groups = useMemo( - () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels), - [providers, search, optionsModel, optionsProvider, visibleModels] + () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), + [providers, search, optionsModel, optionsProvider, effectiveVisibleModels] ) return ( diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 6a103160e65..dc3a4d77382 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -35,12 +35,16 @@ export interface StatusbarItem { menuClassName?: string menuContent?: ReactNode menuItems?: readonly StatusbarMenuItem[] - onSelect?: () => void + onSelect?: (modifiers: StatusbarSelectModifiers) => void title?: string to?: string variant?: 'action' | 'link' | 'menu' | 'text' } +export interface StatusbarSelectModifiers { + shiftKey: boolean +} + export type StatusbarItemSide = 'left' | 'right' export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void @@ -170,12 +174,12 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: