Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c97f0a6c82 | ||
|
|
58e921a819 | ||
|
|
00c46b8ff9 | ||
|
|
8ae0d054f4 | ||
|
|
dd0d1222a2 | ||
|
|
aa6f2775fa | ||
|
|
a5c12f5f59 | ||
|
|
5d7abf9114 | ||
|
|
fc0900d120 | ||
|
|
0904bc7ea2 | ||
|
|
4eb8972390 | ||
|
|
039fbb41fc | ||
|
|
15c99b437f | ||
|
|
7a5827c8b0 | ||
|
|
de5fe2fa7d | ||
|
|
619bd78273 | ||
|
|
02a4d66951 | ||
|
|
4107076128 | ||
|
|
4d18717b6c | ||
|
|
d02a59b679 | ||
|
|
e45b745835 | ||
|
|
e02f4c03c3 | ||
|
|
3714caa1b9 | ||
|
|
329c33dac3 | ||
|
|
d759c13c09 | ||
|
|
694adec635 | ||
|
|
f0fcaa1e54 | ||
|
|
0f500fc41d | ||
|
|
3fc67b7333 | ||
|
|
ede4f5a4a3 | ||
|
|
9d6992ee8a | ||
|
|
1c68f6f81f | ||
|
|
6459b3d991 | ||
|
|
1a626470ca | ||
|
|
524453dab5 | ||
|
|
4d926f248d | ||
|
|
648706936d | ||
|
|
39c4ac3af1 | ||
|
|
cb5c24e37d | ||
|
|
8e223b36ed | ||
|
|
777dc9da62 | ||
|
|
240c5d4543 | ||
|
|
132d6fe6d6 | ||
|
|
f5bd09af4b | ||
|
|
9b631e4ae1 | ||
|
|
2789bf4e25 | ||
|
|
568e127612 | ||
|
|
4da45e8727 | ||
|
|
b2e6053243 | ||
|
|
54870847cb | ||
|
|
86c537d209 | ||
|
|
2a10da3a16 | ||
|
|
b8469a81e3 | ||
|
|
2e62862784 | ||
|
|
b5f7a1f299 | ||
|
|
cca3b77a4b | ||
|
|
8513a6aec7 | ||
|
|
ad8e57793d | ||
|
|
5408013369 | ||
|
|
a77bc2c08d | ||
|
|
48ae8029aa | ||
|
|
bddc5fd087 | ||
|
|
53a2ac8f2d | ||
|
|
ace4b722dc | ||
|
|
0c67d4015f | ||
|
|
78e2101cd2 | ||
|
|
e53b74c394 | ||
|
|
09a5548628 | ||
|
|
2e61de0638 | ||
|
|
f1d3afb151 | ||
|
|
9513793ad7 | ||
|
|
41f0714287 | ||
|
|
18c085b1a4 | ||
|
|
b18490b890 | ||
|
|
38d1a414a1 | ||
|
|
09ec26c66a | ||
|
|
ab0a6270c3 | ||
|
|
133e0271e2 | ||
|
|
b5a457c033 | ||
|
|
d65b513f23 | ||
|
|
86e5efb0ae | ||
|
|
ba29010902 | ||
|
|
e3b8b6d32c | ||
|
|
fa42ac094d | ||
|
|
30c7913617 | ||
|
|
d3b670e63e | ||
|
|
b97cd81c78 | ||
|
|
ad399b9229 | ||
|
|
2aa316ec9c | ||
|
|
4ce9caed04 | ||
|
|
6bdc4c0231 | ||
|
|
628780b4f3 | ||
|
|
c50fb560ef | ||
|
|
69a293b419 | ||
|
|
9c5d1afbe9 | ||
|
|
ae82eed2b1 | ||
|
|
cb83149dc6 | ||
|
|
2b119baac1 | ||
|
|
09d66037f8 | ||
|
|
dde9c0d19d | ||
|
|
e029b7597b | ||
|
|
1c7ae46f0e | ||
|
|
cadb74adad | ||
|
|
c986377236 | ||
|
|
7df81d0557 | ||
|
|
2ee8c983c0 | ||
|
|
0c0fbf763b | ||
|
|
8e71b5136b | ||
|
|
f3af489ec2 | ||
|
|
333f01bc7f | ||
|
|
1892e22acb | ||
|
|
16786f3bb3 | ||
|
|
20fd0bde5d | ||
|
|
0c48b7165d | ||
|
|
af08c43f3e | ||
|
|
76f01780f0 | ||
|
|
9405cd0812 | ||
|
|
cb3e41e2fd | ||
|
|
d87f293972 | ||
|
|
9dbad1990b | ||
|
|
a317e54935 | ||
|
|
f6f363662e | ||
|
|
3fa15b33dd | ||
|
|
410cb743bf | ||
|
|
2912d94370 | ||
|
|
e2cc24e331 | ||
|
|
fa8fd513ea | ||
|
|
f10a330aee | ||
|
|
490c486ff6 | ||
|
|
9d72680ca3 | ||
|
|
1a4010edf5 | ||
|
|
621bf3a873 | ||
|
|
1fb99b1f22 | ||
|
|
02aad08acf | ||
|
|
9e63109522 | ||
|
|
136dae779e | ||
|
|
0507e4630d | ||
|
|
349a3f601c | ||
|
|
ed81cfe3de | ||
|
|
5a3092b601 | ||
|
|
4b9862eb7f | ||
|
|
b55ac45264 | ||
|
|
330ca4585b | ||
|
|
591e6fb8f4 | ||
|
|
ffe665277c | ||
|
|
a216ff839b | ||
|
|
f5c3fc319c | ||
|
|
3c8f1dee8d | ||
|
|
3763355f08 | ||
|
|
e18f14d928 | ||
|
|
0524c9b34e | ||
|
|
2d099fed1e | ||
|
|
3289d4adf2 | ||
|
|
7223f22d65 | ||
|
|
ce4e74b350 | ||
|
|
03392b67d6 | ||
|
|
fe0b3f2338 | ||
|
|
44c0c2d4ac | ||
|
|
eb70ab894b | ||
|
|
fec5ca71d8 | ||
|
|
4d0f2bd241 |
@@ -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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
||||
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
||||
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
|
||||
</p>
|
||||
|
||||
**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.
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
<div dir="rtl">
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
|
||||
</p>
|
||||
|
||||
# ہرمیس ایجنٹ ☤ (Hermes Agent)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
||||
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
|
||||
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
||||
</p>
|
||||
|
||||
**[نوس ریسرچ (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` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔
|
||||
|
||||
<table>
|
||||
<tr><td><b>حقیقی ٹرمینل انٹرفیس</b></td><td>مکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔</td></tr>
|
||||
<tr><td><b>یہ وہاں موجود ہے جہاں آپ ہیں</b></td><td>ٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔</td></tr>
|
||||
<tr><td><b>سیکھنے کا ایک مکمل عمل</b></td><td>ایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ <a href="https://github.com/plastic-labs/honcho">Honcho</a> کے ذریعے صارف کی ماڈلنگ۔ <a href="https://agentskills.io">agentskills.io</a> اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔</td></tr>
|
||||
<tr><td><b>شیڈول کی گئی خودکار کارروائیاں</b></td><td>بلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔</td></tr>
|
||||
<tr><td><b>کام کی تقسیم اور متوازی عمل</b></td><td>متوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔</td></tr>
|
||||
<tr><td><b>کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیں</b></td><td>چھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔</td></tr>
|
||||
<tr><td><b>تحقیق کے لیے تیار</b></td><td>بیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔</td></tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## فوری انسٹالیشن (Quick Install)
|
||||
|
||||
### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
### ونڈوز (نیٹو، پاور شیل)
|
||||
|
||||
> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔
|
||||
|
||||
اسے پاور شیل (PowerShell) میں چلائیں:
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```powershell
|
||||
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
انسٹالر سب کچھ خود سنبھالتا ہے: 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 اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
|
||||
|
||||
انسٹالیشن کے بعد:
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc)
|
||||
hermes # بات چیت شروع کریں!
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## آغاز کریں (Getting Started)
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```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 # کسی بھی مسئلے کی تشخیص کریں
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)**
|
||||
|
||||
---
|
||||
|
||||
## API-کیز اکٹھی کرنے سے بچیں — Nous Portal
|
||||
|
||||
ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
|
||||
|
||||
- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
|
||||
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
|
||||
|
||||
نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔
|
||||
|
||||
آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔
|
||||
|
||||
---
|
||||
|
||||
## CLI بمقابلہ میسجنگ فوری حوالہ
|
||||
|
||||
ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
| کارروائی (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` یا `/<skill-name>` | `/<skill-name>` |
|
||||
| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
|
||||
| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
|
||||
|
||||
</div>
|
||||
|
||||
مکمل کمانڈ لسٹ کے لیے، [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/)** پر موجود ہیں:
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
| سیکشن (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) | مکمل انوائرمنٹ ویری ایبل حوالہ جات |
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## OpenClaw سے منتقلی
|
||||
|
||||
اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔
|
||||
|
||||
**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔
|
||||
|
||||
**انسٹالیشن کے بعد کسی بھی وقت:**
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bash
|
||||
hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ)
|
||||
hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا
|
||||
hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں
|
||||
hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
جو چیزیں امپورٹ ہوتی ہیں:
|
||||
|
||||
- **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` چلائیں:
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```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` کرنے کی ضرورت نہیں
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
مینوئل طریقہ (اوپر والے طریقے کے مساوی):
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## کمیونٹی (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) کی جانب سے تیار کردہ۔
|
||||
|
||||
</div>
|
||||
@@ -10,6 +10,7 @@
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
||||
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
|
||||
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
|
||||
</p>
|
||||
|
||||
**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。
|
||||
|
||||
@@ -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": <dict>}}`` 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}}
|
||||
+95
-5
@@ -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) -------------------------------------------
|
||||
|
||||
+71
-2
@@ -68,6 +68,24 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
|
||||
|
||||
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
|
||||
text is printed inline for CLI users and replayed via ``status_callback``
|
||||
for gateway users, so it must be self-contained and include the exact
|
||||
opt-back-out command.
|
||||
"""
|
||||
from_pct = int(round(autoraise["from"] * 100))
|
||||
to_pct = int(round(autoraise["to"] * 100))
|
||||
return (
|
||||
f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
|
||||
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
|
||||
f"summarizing.\n"
|
||||
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
|
||||
)
|
||||
|
||||
|
||||
def _normalized_custom_base_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -867,6 +885,14 @@ def init_agent(
|
||||
headers["x-anthropic-beta"] = _FINE_GRAINED
|
||||
client_kwargs["default_headers"] = headers
|
||||
|
||||
# User-configured request headers (model.default_headers in
|
||||
# config.yaml) override provider/SDK defaults. Lets custom
|
||||
# OpenAI-compatible endpoints behind a gateway/WAF that rejects the
|
||||
# OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033)
|
||||
# client_kwargs is the same dict object as agent._client_kwargs, so
|
||||
# this mutation is reflected in the client built just below.
|
||||
agent._apply_user_default_headers()
|
||||
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
@@ -1240,11 +1266,41 @@ def init_agent(
|
||||
if not isinstance(_compression_cfg, dict):
|
||||
_compression_cfg = {}
|
||||
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
|
||||
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
|
||||
# 85% (the Codex backend caps the window at 272K, so the default 50% would
|
||||
# compact at ~136K — half the usable context). Gated by an opt-out config
|
||||
# flag so the user can fall back to the global threshold; when the override
|
||||
# fires we stash a one-time notification (replayed on the first turn) that
|
||||
# tells the user what changed and how to revert.
|
||||
_codex_gpt55_autoraise = str(
|
||||
_compression_cfg.get("codex_gpt55_autoraise", True)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
agent._compression_threshold_autoraised = None
|
||||
try:
|
||||
from agent.auxiliary_client import _compression_threshold_for_model as _cthresh_fn
|
||||
_model_cthresh = _cthresh_fn(agent.model)
|
||||
from agent.auxiliary_client import (
|
||||
_compression_threshold_for_model as _cthresh_fn,
|
||||
_is_codex_gpt55 as _is_codex_gpt55_fn,
|
||||
)
|
||||
_model_cthresh = _cthresh_fn(
|
||||
agent.model,
|
||||
agent.provider,
|
||||
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
|
||||
)
|
||||
if _model_cthresh is not None:
|
||||
_prev_threshold = compression_threshold
|
||||
compression_threshold = _model_cthresh
|
||||
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
|
||||
# override is a long-standing silent default). Skip the notice when
|
||||
# the user's global threshold already meets/exceeds the raised
|
||||
# value, since nothing actually changed for them.
|
||||
if (
|
||||
_is_codex_gpt55_fn(agent.model, agent.provider)
|
||||
and _model_cthresh > _prev_threshold + 1e-9
|
||||
):
|
||||
agent._compression_threshold_autoraised = {
|
||||
"from": _prev_threshold,
|
||||
"to": _model_cthresh,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
|
||||
@@ -1621,11 +1677,24 @@ def init_agent(
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
else:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
|
||||
# exact opt-back-out command. Printed inline at startup for CLI users;
|
||||
# gateway users get the same text replayed via _compression_warning on
|
||||
# turn 1 (set below, after the warning slot is initialized).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled:
|
||||
print(_build_codex_gpt55_autoraise_notice(_autoraise))
|
||||
|
||||
# Check immediately so CLI users see the warning at startup.
|
||||
# Gateway status_callback is not yet wired, so any warning is stored
|
||||
# in _compression_warning and replayed in the first run_conversation().
|
||||
agent._compression_warning = None
|
||||
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
|
||||
# above only reaches the CLI, so stash the same text here to be replayed
|
||||
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled:
|
||||
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
|
||||
# Lazy feasibility check: deferred to the first turn that approaches the
|
||||
# compression threshold. Running it eagerly here costs ~400ms cold (network
|
||||
# probe of the auxiliary provider chain + /models lookup) on every agent
|
||||
|
||||
@@ -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(" ", "_")
|
||||
|
||||
|
||||
+178
-62
@@ -202,6 +202,35 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
|
||||
return bare == "trinity-large-thinking"
|
||||
|
||||
|
||||
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
|
||||
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
|
||||
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
|
||||
# chatgpt.com/backend-api/codex/responses is rejected with
|
||||
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
|
||||
# default 50% compaction trigger fires at ~136K — wasteful, since the model
|
||||
# can hold far more raw context before summarization actually buys anything.
|
||||
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
|
||||
# sessions use the window they actually have.
|
||||
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
|
||||
|
||||
|
||||
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
|
||||
|
||||
Matches only the Codex OAuth route (provider ``openai-codex``), not the
|
||||
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
|
||||
larger context window for the same slug and must keep the user's default
|
||||
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
|
||||
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
|
||||
family without re-listing every variant.
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
return False
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
|
||||
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
model: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
@@ -224,18 +253,32 @@ def _fixed_temperature_for_model(
|
||||
return None
|
||||
|
||||
|
||||
def _compression_threshold_for_model(model: Optional[str]) -> Optional[float]:
|
||||
def _compression_threshold_for_model(
|
||||
model: Optional[str],
|
||||
provider: Optional[str] = None,
|
||||
*,
|
||||
allow_codex_gpt55_autoraise: bool = True,
|
||||
) -> Optional[float]:
|
||||
"""Return a context-compression threshold override for specific models.
|
||||
|
||||
The threshold is the fraction of the model's context window that must be
|
||||
consumed before Hermes triggers summarization. Higher values delay
|
||||
compression and preserve more raw context.
|
||||
|
||||
Per-model/route overrides:
|
||||
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
|
||||
- gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
|
||||
at 272K and the default 50% trigger would compact at ~136K. Gated by
|
||||
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
|
||||
global default (the caller passes the config flag through here).
|
||||
|
||||
Returns a float in (0, 1] to override the global ``compression.threshold``
|
||||
config value, or ``None`` to leave the user's config value unchanged.
|
||||
"""
|
||||
if _is_arcee_trinity_thinking(model):
|
||||
return 0.75
|
||||
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
|
||||
return _CODEX_GPT55_COMPACTION_THRESHOLD
|
||||
return None
|
||||
|
||||
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
|
||||
@@ -314,6 +357,35 @@ _OR_HEADERS_BASE = {
|
||||
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def _apply_user_default_headers(headers: dict | None) -> dict | None:
|
||||
"""Merge user-configured ``model.default_headers`` onto resolved headers.
|
||||
|
||||
User values take precedence over provider/SDK defaults, mirroring the main
|
||||
agent client (``AIAgent._apply_user_default_headers``). This lets a
|
||||
``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the
|
||||
OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``,
|
||||
``X-Stainless-*``) override them for auxiliary calls too — otherwise the
|
||||
main turn would succeed but title/compression/vision calls to the same
|
||||
endpoint would still fail. (#40033)
|
||||
|
||||
Returns the merged dict, or the original ``headers`` (possibly ``None``)
|
||||
when nothing is configured. No allocation when there are no overrides.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
user_headers = cfg_get(load_config(), "model", "default_headers")
|
||||
except Exception:
|
||||
return headers
|
||||
if not isinstance(user_headers, dict) or not user_headers:
|
||||
return headers
|
||||
merged = dict(headers or {})
|
||||
for key, value in user_headers.items():
|
||||
if value is None:
|
||||
continue
|
||||
merged[str(key)] = str(value)
|
||||
return merged or headers
|
||||
|
||||
|
||||
def build_or_headers(or_config: dict | None = None) -> dict:
|
||||
"""Build OpenRouter headers, optionally including response-cache headers.
|
||||
|
||||
@@ -565,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."""
|
||||
@@ -625,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,
|
||||
}
|
||||
|
||||
@@ -1452,6 +1487,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
extra["default_headers"] = dict(_ph_aux.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_aux:
|
||||
extra["default_headers"] = _merged_aux
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
@@ -1489,6 +1527,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
extra["default_headers"] = dict(_ph_aux2.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_aux2:
|
||||
extra["default_headers"] = _merged_aux2
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
@@ -1879,6 +1920,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
|
||||
_clean_base, _dq = _extract_url_query_params(custom_base)
|
||||
_extra = {"default_query": _dq} if _dq else {}
|
||||
# User-configured model.default_headers override the SDK's identifying
|
||||
# headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom
|
||||
# endpoint's auxiliary calls too — matching the main agent client so the
|
||||
# whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033)
|
||||
_custom_headers = _apply_user_default_headers(None)
|
||||
if _custom_headers:
|
||||
_extra["default_headers"] = _custom_headers
|
||||
if custom_mode == "codex_responses":
|
||||
real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
@@ -2428,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)
|
||||
@@ -3248,6 +3315,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
async_kwargs["default_headers"] = dict(_ph_async.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
|
||||
if _merged_async:
|
||||
async_kwargs["default_headers"] = _merged_async
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
|
||||
|
||||
@@ -3535,6 +3605,9 @@ def resolve_provider_client(
|
||||
extra["default_headers"] = dict(_ph_custom.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_custom:
|
||||
extra["default_headers"] = _merged_custom
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
@@ -3611,6 +3684,9 @@ def resolve_provider_client(
|
||||
raw_base_for_wrap = custom_base
|
||||
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
|
||||
_extra2 = {"default_query": _dq2} if _dq2 else {}
|
||||
_headers2 = _apply_user_default_headers(_extra2.get("default_headers"))
|
||||
if _headers2:
|
||||
_extra2["default_headers"] = _headers2
|
||||
logger.debug(
|
||||
"resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
|
||||
provider, final_model, entry_api_mode or "chat_completions")
|
||||
@@ -3633,6 +3709,9 @@ def resolve_provider_client(
|
||||
_fallback_base = _to_openai_base_url(custom_base)
|
||||
_fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
|
||||
_fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
|
||||
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
|
||||
if _fb_headers:
|
||||
_fb_extra["default_headers"] = _fb_headers
|
||||
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
@@ -3781,6 +3860,9 @@ def resolve_provider_client(
|
||||
headers.update(_ph_main.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_main = _apply_user_default_headers(headers)
|
||||
if _merged_main:
|
||||
headers = _merged_main
|
||||
client = OpenAI(api_key=api_key, base_url=base_url,
|
||||
**({"default_headers": headers} if headers else {}))
|
||||
|
||||
@@ -5084,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)
|
||||
@@ -5551,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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -34,7 +34,7 @@ from agent.message_sanitization import (
|
||||
_repair_tool_call_arguments,
|
||||
)
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from utils import base_url_host_matches, base_url_hostname, env_int
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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:
|
||||
@@ -1936,6 +1972,20 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
),
|
||||
))
|
||||
|
||||
# Zero-chunk guard: stream yielded nothing usable — a provider/upstream
|
||||
# error or malformed SSE, not a legitimate empty completion. Raise so the
|
||||
# retry machinery handles it instead of fabricating a successful turn.
|
||||
if (
|
||||
finish_reason is None
|
||||
and not content_parts
|
||||
and not reasoning_parts
|
||||
and not tool_calls_acc
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Provider returned an empty stream with no finish_reason "
|
||||
"(possible upstream error or malformed SSE response)."
|
||||
)
|
||||
|
||||
effective_finish_reason = finish_reason or "stop"
|
||||
if has_truncated_tool_args:
|
||||
effective_finish_reason = "length"
|
||||
@@ -2044,7 +2094,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
def _call():
|
||||
import httpx as _httpx
|
||||
|
||||
_max_stream_retries = int(os.getenv("HERMES_STREAM_RETRIES", 2))
|
||||
_max_stream_retries = env_int("HERMES_STREAM_RETRIES", 2)
|
||||
|
||||
try:
|
||||
for _stream_attempt in range(_max_stream_retries + 1):
|
||||
@@ -2064,6 +2114,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)
|
||||
)
|
||||
@@ -2373,6 +2438,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()
|
||||
|
||||
+105
-1
@@ -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,
|
||||
@@ -1247,6 +1263,19 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
summary_budget = self._compute_summary_budget(turns_to_summarize)
|
||||
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
|
||||
|
||||
# Current date for temporal anchoring (see ## Temporal Anchoring below).
|
||||
# Date-only granularity matches system_prompt.py:337 (PR #20451) and the
|
||||
# user's configured timezone via hermes_time.now(). The compaction summary
|
||||
# is a mid-conversation message that is NOT part of the cached prefix, so a
|
||||
# date here never affects prompt-cache stability. Resolved defensively —
|
||||
# a clock failure must never block compaction.
|
||||
try:
|
||||
from hermes_time import now as _hermes_now
|
||||
|
||||
_today_str = _hermes_now().strftime("%Y-%m-%d")
|
||||
except Exception: # pragma: no cover - clock resolution is best-effort
|
||||
_today_str = ""
|
||||
|
||||
# Preamble shared by both first-compaction and iterative-update prompts.
|
||||
# Keep the wording deliberately plain: Azure/OpenAI-compatible content
|
||||
# filters have flagged stronger "injection" / "do not respond" framing.
|
||||
@@ -1264,6 +1293,24 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
"do not preserve their values."
|
||||
)
|
||||
|
||||
# Temporal anchoring directive. Rewrites relative / still-pending-sounding
|
||||
# references into absolute, dated, past-tense facts so a resumed
|
||||
# conversation does not re-issue completed actions. Only emitted when the
|
||||
# current date resolved successfully; otherwise the rule is omitted so the
|
||||
# summarizer is never handed an empty date placeholder.
|
||||
if _today_str:
|
||||
_temporal_anchoring_rule = (
|
||||
f"\nTEMPORAL ANCHORING: The current date is {_today_str}. When an "
|
||||
"action has already been carried out, phrase it as a completed, "
|
||||
"dated, past-tense fact rather than an open instruction. For "
|
||||
'example, rewrite "email John about the proposal" as "Sent the '
|
||||
f'proposal email to John on {_today_str}." Never leave a finished '
|
||||
"action worded as if it still needs doing, and never invent a date "
|
||||
"for work that has not happened yet.\n"
|
||||
)
|
||||
else:
|
||||
_temporal_anchoring_rule = ""
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
@@ -1337,7 +1384,7 @@ Be specific with file paths, commands, line numbers, and results.]
|
||||
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
|
||||
|
||||
Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed.
|
||||
|
||||
{_temporal_anchoring_rule}
|
||||
Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
if self._previous_summary:
|
||||
@@ -1787,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)
|
||||
@@ -1889,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]
|
||||
@@ -1909,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+85
-452
@@ -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,356 +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 <id>`` 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
|
||||
|
||||
# ── 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.
|
||||
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
|
||||
_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
|
||||
@@ -750,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.
|
||||
@@ -1152,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
|
||||
@@ -1200,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.
|
||||
@@ -1525,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)
|
||||
@@ -1596,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()
|
||||
@@ -1820,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()
|
||||
@@ -2069,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
|
||||
@@ -2097,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:
|
||||
@@ -2399,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 — "
|
||||
@@ -2410,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,
|
||||
)
|
||||
@@ -2427,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 — "
|
||||
@@ -2452,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 — "
|
||||
@@ -2481,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:
|
||||
@@ -2502,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...")
|
||||
@@ -2513,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
|
||||
@@ -2544,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
|
||||
@@ -2554,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():
|
||||
@@ -2597,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)
|
||||
@@ -2631,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(
|
||||
@@ -2642,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 — "
|
||||
@@ -2669,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)
|
||||
@@ -2882,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.
|
||||
@@ -2915,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 ─────
|
||||
@@ -3053,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
|
||||
@@ -3125,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
|
||||
@@ -3210,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
|
||||
@@ -3315,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(
|
||||
@@ -3447,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
|
||||
@@ -3459,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()
|
||||
@@ -3610,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.
|
||||
|
||||
@@ -374,7 +374,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:<name>' for a matching base_url.
|
||||
|
||||
When provider_name is given, prefer matching by name first (solving the case where
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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:<name>" 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
|
||||
|
||||
|
||||
|
||||
+8
-17
@@ -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)
|
||||
|
||||
+203
-29
@@ -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.
|
||||
|
||||
|
||||
@@ -964,6 +964,10 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
is_output_cap_error = (
|
||||
"max_tokens" in error_lower
|
||||
and ("available_tokens" in error_lower or "available tokens" in error_lower)
|
||||
) or (
|
||||
# OpenRouter/Nous phrasing of the same condition.
|
||||
"in the output" in error_lower
|
||||
and "maximum context length" in error_lower
|
||||
)
|
||||
if not is_output_cap_error:
|
||||
return None
|
||||
@@ -982,6 +986,19 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
tokens = int(match.group(1))
|
||||
if tokens >= 1:
|
||||
return tokens
|
||||
|
||||
# OpenRouter/Nous format: "maximum context length is N … (A of text input,
|
||||
# B of tool input, C in the output)". Available output = ctx - text - tool.
|
||||
_m_ctx = re.search(r'maximum context length is (\d+)', error_lower)
|
||||
_m_parts = re.search(
|
||||
r'\((\d+)\s+of text input,\s*(\d+)\s+of tool input,\s*(\d+)\s+in the output\)',
|
||||
error_lower,
|
||||
)
|
||||
if _m_ctx and _m_parts:
|
||||
_available = int(_m_ctx.group(1)) - int(_m_parts.group(1)) - int(_m_parts.group(2))
|
||||
if _available >= 1:
|
||||
return _available
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -1667,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)
|
||||
|
||||
@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
|
||||
BUSY_INPUT_FLAG = "busy_input_prompt"
|
||||
TOOL_PROGRESS_FLAG = "tool_progress_prompt"
|
||||
OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
|
||||
PROFILE_BUILD_FLAG = "profile_build_offered"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -126,6 +127,62 @@ def detect_openclaw_residue(home: Optional[Path] = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Onboarding profile-build path (opt-in, consent-gated)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def profile_build_mode(config: Mapping[str, Any]) -> str:
|
||||
"""Resolve the onboarding profile-build mode from config.
|
||||
|
||||
Returns one of:
|
||||
``"ask"`` — on first contact, OFFER to build a profile (default).
|
||||
``"off"`` — never offer; the first-message note stays a plain intro.
|
||||
|
||||
Read from ``config.onboarding.profile_build``. Unknown / missing values
|
||||
fall back to ``"ask"`` so the default experience offers the flow. Any
|
||||
network/account lookups inside the flow are separately consented to in
|
||||
conversation — this setting only governs whether the offer is made.
|
||||
"""
|
||||
if not isinstance(config, Mapping):
|
||||
return "ask"
|
||||
onboarding = config.get("onboarding")
|
||||
if not isinstance(onboarding, Mapping):
|
||||
return "ask"
|
||||
mode = onboarding.get("profile_build")
|
||||
if isinstance(mode, str) and mode.strip().lower() == "off":
|
||||
return "off"
|
||||
return "ask"
|
||||
|
||||
|
||||
def profile_build_directive() -> str:
|
||||
"""System-note directive appended to the very first message ever.
|
||||
|
||||
Instructs the agent to run a short, opt-in, consent-gated profile-build
|
||||
flow and persist confirmed facts to the user-profile memory store
|
||||
(``memory`` tool, ``target="user"``). Phrased so the agent ASKS before any
|
||||
lookup and never silently reads connected accounts — directly addressing
|
||||
the privacy concern that reading email/accounts unprompted feels invasive.
|
||||
"""
|
||||
return (
|
||||
"\n\n[System note: This is the user's very first message ever. "
|
||||
"After a one-sentence introduction (mention /help shows commands), "
|
||||
"OFFER — do not assume — to build a short profile of them so you can "
|
||||
"be more useful, and explain they can decline or do it later. If and "
|
||||
"ONLY IF they accept:\n"
|
||||
" 1. Ask for whatever they're comfortable sharing (name, what they "
|
||||
"do, how they like you to work). Volunteered facts come first.\n"
|
||||
" 2. Before ANY external lookup, say what you intend to look up and "
|
||||
"get explicit consent for that step. Never read their connected "
|
||||
"accounts (email, calendar, etc.) silently — ask each time.\n"
|
||||
" 3. With consent, you may use web_search to confirm public details "
|
||||
"(e.g. employer, public profiles) from the data points they gave.\n"
|
||||
" 4. Save each confirmed, durable fact with the memory tool using "
|
||||
"target=\"user\" — keep entries compact and high-signal.\n"
|
||||
"If they decline at any point, stop immediately and continue normally. "
|
||||
"Keep the whole exchange light and conversational, not an interrogation.]"
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# State read / write
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -182,12 +239,15 @@ __all__ = [
|
||||
"BUSY_INPUT_FLAG",
|
||||
"TOOL_PROGRESS_FLAG",
|
||||
"OPENCLAW_RESIDUE_FLAG",
|
||||
"PROFILE_BUILD_FLAG",
|
||||
"busy_input_hint_gateway",
|
||||
"busy_input_hint_cli",
|
||||
"tool_progress_hint_gateway",
|
||||
"tool_progress_hint_cli",
|
||||
"openclaw_residue_hint_cli",
|
||||
"detect_openclaw_residue",
|
||||
"profile_build_mode",
|
||||
"profile_build_directive",
|
||||
"is_seen",
|
||||
"mark_seen",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -76,6 +76,21 @@ function bootstrapCacheDir(hermesHome) {
|
||||
return path.join(hermesHome, 'bootstrap-cache')
|
||||
}
|
||||
|
||||
// The install.sh / install.ps1 that ships inside the already-installed agent
|
||||
// checkout under ~/.hermes/hermes-agent. Used as a last-resort fallback when
|
||||
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
|
||||
// app stamped to an unpushed HEAD).
|
||||
function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) return null
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
}
|
||||
@@ -155,7 +170,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
|
||||
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
||||
// of APP_ROOT/../..).
|
||||
@@ -189,9 +204,35 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
|
||||
type: 'log',
|
||||
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
|
||||
})
|
||||
await downloadInstallScript(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
try {
|
||||
await _download(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch (err) {
|
||||
// The pinned commit may not be fetchable from GitHub -- most commonly a
|
||||
// locally-built desktop app stamped to an unpushed HEAD (see
|
||||
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
|
||||
// ships inside the already-installed agent checkout so dev/self-builds can
|
||||
// still bootstrap instead of dying with a fatal 404.
|
||||
const installed = installedAgentInstallScript(hermesHome)
|
||||
if (installed) {
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
`[bootstrap] GitHub fetch failed (${err.message}); ` +
|
||||
`falling back to installed agent ${installScriptName()} at ${installed}`
|
||||
})
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.copyFileSync(installed, cached)
|
||||
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
|
||||
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -673,5 +714,7 @@ module.exports = {
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveLocalInstallScript,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
runBootstrap,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
} = require('./bootstrap-runner.cjs')
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
|
||||
function mkTmpHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
|
||||
}
|
||||
|
||||
test('runBootstrap bails immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
@@ -25,3 +39,100 @@ test('runBootstrap bails immediately when the signal is already aborted', async
|
||||
'should emit a cancelled failure event'
|
||||
)
|
||||
})
|
||||
|
||||
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
|
||||
|
||||
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
|
||||
fs.mkdirSync(scriptsDir, { recursive: true })
|
||||
const scriptPath = path.join(scriptsDir, SCRIPT_NAME)
|
||||
fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n')
|
||||
|
||||
assert.equal(installedAgentInstallScript(home), scriptPath)
|
||||
assert.equal(installedAgentInstallScript(null), null, 'null home -> null')
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript prefers a cached script without touching the network', async () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
const cached = cachedScriptPath(home, commit)
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
|
||||
|
||||
const logs = []
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev)
|
||||
})
|
||||
|
||||
assert.equal(result.source, 'cache')
|
||||
assert.equal(result.path, cached)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// Seed the installed agent checkout so the fallback has something to resolve.
|
||||
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
|
||||
fs.mkdirSync(scriptsDir, { recursive: true })
|
||||
const installed = path.join(scriptsDir, SCRIPT_NAME)
|
||||
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
|
||||
|
||||
const logs = []
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev),
|
||||
// Simulate GitHub returning a 404 for the pinned commit.
|
||||
_download: async () => {
|
||||
throw new Error('Failed to download install.sh: HTTP 404')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(result.source, 'installed-agent')
|
||||
// It should have copied the installer into the bootstrap cache.
|
||||
assert.equal(result.path, cachedScriptPath(home, commit))
|
||||
assert.ok(fs.existsSync(result.path), 'fallback script copied into cache')
|
||||
assert.ok(
|
||||
logs.some(ev => /falling back to installed agent/.test(ev.line || '')),
|
||||
'emits a fallback log line'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// No installed agent checkout seeded -> nothing to fall back to.
|
||||
await assert.rejects(
|
||||
resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: () => {},
|
||||
_download: async () => {
|
||||
throw new Error('Failed to download install.sh: HTTP 404')
|
||||
}
|
||||
}),
|
||||
/HTTP 404|Failed to download/
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
+105
-13
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4614,7 +4666,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 +4782,7 @@ function createWindow() {
|
||||
}
|
||||
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
restorePersistedZoomLevel(mainWindow)
|
||||
broadcastBootProgress()
|
||||
sendWindowStateChanged()
|
||||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||||
@@ -4737,6 +4790,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 }
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
@@ -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 }
|
||||
@@ -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'), '<!doctype html><div id=root></div>', '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'), '<!doctype 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 })
|
||||
}
|
||||
})
|
||||
@@ -124,7 +124,10 @@ function ChatHeader({
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
|
||||
>
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
@@ -135,11 +138,11 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
|
||||
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
|
||||
@@ -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<T>(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<string, SidebarSessionGroup>()
|
||||
|
||||
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<string, SidebarSessionGroup>()
|
||||
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<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const agentOrderIds = useStore($sidebarSessionOrderIds)
|
||||
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
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"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && (
|
||||
{contentVisible && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{s.nav[item.id] ?? item.label}
|
||||
</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
/>
|
||||
)}
|
||||
@@ -645,7 +811,7 @@ export function ChatSidebar({
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
{contentVisible && showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label={s.searchAria}
|
||||
@@ -657,7 +823,7 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && trimmedQuery && (
|
||||
{contentVisible && showSessionSections && trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
@@ -681,7 +847,7 @@ export function ChatSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
{contentVisible && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
@@ -703,7 +869,7 @@ export function ChatSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
{contentVisible && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn(
|
||||
@@ -727,7 +893,7 @@ export function ChatSidebar({
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
|
||||
groups={displayAgentGroups}
|
||||
headerAction={
|
||||
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
||||
// same height whether or not the toggle renders — otherwise the
|
||||
@@ -736,7 +902,7 @@ export function ChatSidebar({
|
||||
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
{!showAllProfiles && localAgentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
@@ -770,13 +936,13 @@ export function ChatSidebar({
|
||||
open={agentsOpen}
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={agentSessions}
|
||||
sessions={displayAgentSessions}
|
||||
sortable={!showAllProfiles && agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
|
||||
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
|
||||
<SidebarCronJobsSection
|
||||
jobs={cronJobs}
|
||||
label={s.cronJobs}
|
||||
@@ -788,9 +954,9 @@ export function ChatSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
|
||||
{sidebarOpen && (
|
||||
{contentVisible && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
@@ -872,8 +1038,9 @@ interface SidebarSessionGroup {
|
||||
// Profile color for the ALL-profiles view; absent for workspace groups.
|
||||
color?: null | string
|
||||
loadingMore?: boolean
|
||||
mode?: 'profile' | 'workspace'
|
||||
mode?: 'profile' | 'source' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
sourceId?: string
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
@@ -928,7 +1095,8 @@ function SidebarSessionsSection({
|
||||
onReorder,
|
||||
dndSensors
|
||||
}: SidebarSessionsSectionProps) {
|
||||
const showEmptyState = forceEmptyState || sessions.length === 0
|
||||
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
|
||||
const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0)
|
||||
const dndActive = sortable && !!onReorder
|
||||
|
||||
const renderRow = (session: SessionInfo) => {
|
||||
@@ -961,12 +1129,25 @@ function SidebarSessionsSection({
|
||||
renderRows(items)
|
||||
)
|
||||
|
||||
const renderNestedSessionList = (items: SessionInfo[]) =>
|
||||
dndActive ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
|
||||
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderRows(items)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
renderRows(items)
|
||||
)
|
||||
|
||||
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
|
||||
|
||||
let inner: React.ReactNode
|
||||
let bodyOwnsDndContext = dndActive && !showEmptyState
|
||||
|
||||
if (showEmptyState) {
|
||||
inner = emptyState
|
||||
bodyOwnsDndContext = false
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
@@ -974,7 +1155,7 @@ function SidebarSessionsSection({
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
renderRows={renderNestedSessionList}
|
||||
/>
|
||||
) : (
|
||||
<SidebarWorkspaceGroup
|
||||
@@ -987,12 +1168,15 @@ function SidebarSessionsSection({
|
||||
)
|
||||
|
||||
inner = dndActive ? (
|
||||
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
|
||||
{groupNodes}
|
||||
</SortableContext>
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
|
||||
<SortableContext items={groups.map(g => groupDndId(g.id))} strategy={verticalListSortingStrategy}>
|
||||
{groupNodes}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
groupNodes
|
||||
)
|
||||
bodyOwnsDndContext = false
|
||||
} else if (flatVirtualized) {
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
@@ -1011,14 +1195,13 @@ function SidebarSessionsSection({
|
||||
inner = renderSessionList(sessions)
|
||||
}
|
||||
|
||||
const body =
|
||||
dndActive && !showEmptyState ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
|
||||
{inner}
|
||||
</DndContext>
|
||||
) : (
|
||||
inner
|
||||
)
|
||||
const body = bodyOwnsDndContext ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
|
||||
{inner}
|
||||
</DndContext>
|
||||
) : (
|
||||
inner
|
||||
)
|
||||
|
||||
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
|
||||
// to avoid a double scroll container.
|
||||
@@ -1061,6 +1244,7 @@ function SidebarWorkspaceGroup({
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
const isSourceGroup = group.mode === 'source'
|
||||
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
|
||||
const [open, setOpen] = useState(true)
|
||||
const [visibleCount, setVisibleCount] = useState(pageStep)
|
||||
@@ -1086,7 +1270,16 @@ function SidebarWorkspaceGroup({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-px data-[dragging=true]:z-10 data-[dragging=true]:opacity-70 data-[dragging=true]:will-change-transform',
|
||||
className
|
||||
)}
|
||||
data-dragging={dragging ? 'true' : undefined}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
@@ -1094,7 +1287,18 @@ function SidebarWorkspaceGroup({
|
||||
type="button"
|
||||
>
|
||||
{group.color ? (
|
||||
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
) : null}
|
||||
{isSourceGroup && group.sourceId ? (
|
||||
<PlatformAvatar
|
||||
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
|
||||
platformId={group.sourceId}
|
||||
platformName={group.label}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>
|
||||
@@ -1143,7 +1347,11 @@ function SidebarWorkspaceGroup({
|
||||
{renderRows(visibleSessions)}
|
||||
{hiddenCount > 0 &&
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
<SidebarLoadMoreRow
|
||||
loading={Boolean(group.loadingMore)}
|
||||
onClick={handleProfileLoadMore}
|
||||
step={nextCount}
|
||||
/>
|
||||
) : (
|
||||
<Tip label={s.showMoreIn(nextCount, group.label)}>
|
||||
<button
|
||||
@@ -1169,7 +1377,7 @@ interface SortableWorkspaceProps {
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
|
||||
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(groupDndId(props.group.id))} />
|
||||
}
|
||||
|
||||
function SidebarCount({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -176,8 +176,8 @@ export function SidebarSessionRow({
|
||||
needsInput ? 'overflow-visible' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
@@ -23,13 +24,21 @@ import {
|
||||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
pinSession,
|
||||
setSidebarOverlayMounted,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$freshSessionRequest,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile
|
||||
} from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -69,6 +78,7 @@ import { CommandPalette } from './command-palette'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
@@ -157,6 +167,11 @@ export function DesktopController() {
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const profileScope = useStore($profileScope)
|
||||
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
|
||||
// collapse both sidebars (without touching their stored open state) so the
|
||||
// hover-reveal overlay becomes the way in. Restores once it's wide again.
|
||||
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
|
||||
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
||||
@@ -288,7 +303,12 @@ export function DesktopController() {
|
||||
// the same rows tagged profile="default". Cron sessions are excluded here
|
||||
// and fetched separately (refreshCronSessions) so the scheduler's
|
||||
// always-newest rows can't consume the recents page budget.
|
||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', 'all', {
|
||||
// Scope the fetch to the active profile (not always 'all') so a profile
|
||||
// with few recent sessions isn't windowed out of the cross-profile
|
||||
// recency page — the empty-history-on-profile-switch bug.
|
||||
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
||||
|
||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
@@ -305,7 +325,7 @@ export function DesktopController() {
|
||||
|
||||
void refreshCronSessions()
|
||||
void refreshCronJobs()
|
||||
}, [refreshCronSessions, refreshCronJobs])
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
bumpSessionsLimit()
|
||||
@@ -834,6 +854,8 @@ export function DesktopController() {
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
disabled={!chatOpen}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="file-browser"
|
||||
key="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
@@ -861,9 +883,12 @@ export function DesktopController() {
|
||||
>
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||
resizable
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
|
||||
@@ -120,6 +120,13 @@ export function useGatewayBoot({
|
||||
reconnecting = true
|
||||
|
||||
try {
|
||||
// Drop a stale REMOTE backend cache before re-dialing. After sleep/wake a
|
||||
// remote backend can become unreachable, but it has no child process
|
||||
// whose 'exit' would clear the main process's cached descriptor — without
|
||||
// this the renderer re-dials the same dead endpoint forever and stays on
|
||||
// "Starting Hermes…". The probe is a no-op for a healthy or local backend.
|
||||
await desktop.revalidateConnection?.().catch(() => undefined)
|
||||
|
||||
const conn = await desktop.getConnection($activeGatewayProfile.get())
|
||||
|
||||
if (cancelled) {
|
||||
@@ -218,6 +225,15 @@ export function useGatewayBoot({
|
||||
reconnectAttempt = 0
|
||||
reauthNotified = false
|
||||
clearReconnectTimer()
|
||||
|
||||
// A revalidate-driven reconnect can rebuild the backend in place when the
|
||||
// cached remote was found dead, which re-drives the boot-progress overlay.
|
||||
// Unlike the initial boot, nothing calls completeDesktopBoot() afterwards,
|
||||
// so dismiss it here once we're open again — otherwise the overlay sticks
|
||||
// at ~94%. A no-op on a normal (non-rebuild) reconnect.
|
||||
if (bootCompleted) {
|
||||
completeDesktopBoot()
|
||||
}
|
||||
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
||||
// The socket dropped after a healthy boot (typically sleep/wake). Try
|
||||
// to bring it back instead of leaving the composer stuck disabled.
|
||||
|
||||
@@ -2,11 +2,15 @@ import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
CHAT_SIDEBAR_PANE_ID,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
requestSessionSearchFocus,
|
||||
setFileBrowserOpen,
|
||||
toggleFileBrowserOpen,
|
||||
@@ -24,6 +28,7 @@ import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
@@ -109,8 +114,20 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
|
||||
'view.toggleSidebar': toggleSidebarOpen,
|
||||
'view.toggleRightSidebar': toggleFileBrowserOpen,
|
||||
'view.toggleSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
|
||||
} else {
|
||||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
'view.toggleRightSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
|
||||
} else {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
'view.showFiles': () => showRightSidebarTab('files'),
|
||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
@@ -11,3 +11,9 @@ export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
|
||||
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
|
||||
// out to the gutter edges before re-applying PAGE_INSET_X.
|
||||
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'
|
||||
|
||||
// Below this viewport width a docked sidebar leaves no room for content, so both
|
||||
// rails auto-collapse into the hover-reveal overlay. Single source of truth for
|
||||
// the responsive collapse point.
|
||||
export const SIDEBAR_COLLAPSE_BREAKPOINT_PX = 768
|
||||
export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_COLLAPSE_BREAKPOINT_PX}px)`
|
||||
|
||||
@@ -28,15 +28,17 @@ import { cn } from '@/lib/utils'
|
||||
type IconKind = 'brand' | 'generic'
|
||||
|
||||
interface PlatformIconSpec {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
Icon?: ComponentType<SVGProps<SVGSVGElement>>
|
||||
color: string
|
||||
kind: IconKind
|
||||
monogram?: string
|
||||
}
|
||||
|
||||
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
|
||||
// Slack removed from Simple Icons by Salesforce request — letter monogram.
|
||||
slack: { color: '#4A154B', kind: 'brand', monogram: 'S' },
|
||||
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
|
||||
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
|
||||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
@@ -87,7 +89,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
|
||||
color
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -451,7 +451,8 @@ export function useMessageStream({
|
||||
busy: false,
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +542,8 @@ export function useMessageStream({
|
||||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -599,7 +601,8 @@ export function useMessageStream({
|
||||
sawAssistantPayload: true,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -683,7 +686,8 @@ export function useMessageStream({
|
||||
if (busy) {
|
||||
return {
|
||||
...state,
|
||||
busy
|
||||
busy,
|
||||
turnStartedAt: state.turnStartedAt ?? Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,7 +700,8 @@ export function useMessageStream({
|
||||
awaitingResponse: false,
|
||||
busy,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -735,7 +740,8 @@ export function useMessageStream({
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
interrupted: false,
|
||||
turnStartedAt: Date.now()
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$messages,
|
||||
$yoloActive,
|
||||
setAwaitingResponse,
|
||||
@@ -80,6 +81,28 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
|
||||
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
|
||||
}
|
||||
|
||||
function imageFilenameFromPath(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
|
||||
}
|
||||
|
||||
// Remote gateway: the local composer-image file lives on THIS machine's disk,
|
||||
// not the gateway's, so read the bytes here and upload them via
|
||||
// image.attach_bytes. Returns null when the file can't be read.
|
||||
async function readImageForRemoteAttach(
|
||||
filePath: string
|
||||
): Promise<{ contentBase64: string; filename: string } | null> {
|
||||
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
|
||||
|
||||
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
||||
}
|
||||
|
||||
interface PromptActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
@@ -197,16 +220,36 @@ export function usePromptActions({
|
||||
) => {
|
||||
const updateComposerAttachments = options.updateComposerAttachments ?? true
|
||||
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
|
||||
for (const attachment of images) {
|
||||
if (attachment.attachedSessionId === sessionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
let result: ImageAttachResponse
|
||||
|
||||
if (remote) {
|
||||
// The gateway is on another machine — it can't read attachment.path
|
||||
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
|
||||
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
|
||||
|
||||
if (!payload) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(`Could not read ${label}`)
|
||||
}
|
||||
|
||||
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||
session_id: sessionId,
|
||||
content_base64: payload.contentBase64,
|
||||
filename: payload.filename
|
||||
})
|
||||
} else {
|
||||
result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
}
|
||||
|
||||
if (!result.attached) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
type Cache = ReturnType<typeof useSessionStateCache>
|
||||
|
||||
interface HarnessProps {
|
||||
activeSessionId: string | null
|
||||
onReady: (cache: Cache) => void
|
||||
selectedStoredSessionId: string | null
|
||||
}
|
||||
|
||||
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
|
||||
const busyRef: MutableRefObject<boolean> = { current: false }
|
||||
const cache = useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse: () => undefined,
|
||||
setBusy: () => undefined,
|
||||
setMessages: () => undefined
|
||||
})
|
||||
|
||||
onReady(cache)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useSessionStateCache — per-session turn timer', () => {
|
||||
beforeEach(() => {
|
||||
// The view-sync flush runs on a real rAF in the browser path; in jsdom we
|
||||
// want it synchronous so the global mirror is observable immediately. The
|
||||
// hook closes over `window.requestAnimationFrame`, so stub that exact ref.
|
||||
// Return null (not a handle) so the hook's `viewSyncRafRef.current = rAF(...)`
|
||||
// assignment doesn't overwrite the null the synchronous callback just set —
|
||||
// otherwise the ref reads truthy and the NEXT sync is suppressed (a real
|
||||
// browser returns a handle but runs the callback async, so this race is a
|
||||
// test-only artifact of firing synchronously).
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
|
||||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
let cache!: Cache
|
||||
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
|
||||
render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
const startedAt = 1_700_000_000_000
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// The background session's own cache entry holds the clock...
|
||||
expect(cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')?.turnStartedAt).toBe(startedAt)
|
||||
// ...but the global atom (statusbar timer) is untouched — a background turn
|
||||
// must not drive the foreground timer.
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it("mirrors the focused session's turn clock into the global atom on view-sync", () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
|
||||
|
||||
const startedAt = 1_700_000_111_000
|
||||
|
||||
// A turn on the ACTIVE session stages into the view; the flush mirrors its
|
||||
// turnStartedAt into the global atom the statusbar reads.
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'fg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
|
||||
'fg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
expect($turnStartedAt.get()).toBe(startedAt)
|
||||
})
|
||||
|
||||
it('clears the global clock when the focused turn ends', () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'fg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: 1_700_000_222_000 }),
|
||||
'fg-stored'
|
||||
)
|
||||
})
|
||||
expect($turnStartedAt.get()).toBe(1_700_000_222_000)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: false, turnStartedAt: null }))
|
||||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,32 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
// Shallow per-message identity check. When a flush carries no transcript
|
||||
// changes, `preserveLocalAssistantErrors` returns the same message objects in
|
||||
// the same order, so reference equality per slot is enough to detect "nothing
|
||||
// to publish" and avoid a needless `$messages` churn.
|
||||
function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface SessionStateCacheOptions {
|
||||
activeSessionId: string | null
|
||||
busyRef: MutableRefObject<boolean>
|
||||
@@ -88,10 +110,27 @@ export function useSessionStateCache({
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
|
||||
// `preserveLocalAssistantErrors` always returns a fresh array, so publishing
|
||||
// it unconditionally puts a new `$messages` reference on the store every
|
||||
// flush — including the periodic `session.info` heartbeats that don't touch
|
||||
// the transcript. That churns ChatView → runtimeMessageRepository → the
|
||||
// assistant-ui runtime → the virtualizer, which re-measures and visibly
|
||||
// jerks the scroll position while the user is reading. Skip the publish when
|
||||
// the merged result is content-identical to what's already on screen.
|
||||
const currentMessages = $messages.get()
|
||||
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
|
||||
|
||||
if (!sameMessageList(nextMessages, currentMessages)) {
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
// Mirror the focused session's per-session turn clock into the global
|
||||
// atom the statusbar timer reads. Keeps a backgrounded turn's elapsed
|
||||
// time intact on focus instead of zeroing it (the "timer restarts" bug).
|
||||
setTurnStartedAt(pending.state.turnStartedAt)
|
||||
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
|
||||
@@ -240,9 +240,37 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
||||
'context.engine': ['compressor', 'default', 'custom'],
|
||||
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
|
||||
'memory.provider': ['', 'builtin', 'honcho'],
|
||||
// Terminal execution backends — kept in sync with the dispatch ladder in
|
||||
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
|
||||
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
|
||||
'terminal.backend': ['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'],
|
||||
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
|
||||
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
|
||||
// Speech-to-text backends — kept in sync with the stt block in
|
||||
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
|
||||
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
|
||||
// Text-to-speech backends — kept in sync with the built-in source of truth
|
||||
// (agent/tts_registry.py::_BUILTIN_NAMES / tools/tts_tool.py::
|
||||
// BUILTIN_TTS_PROVIDERS). 'xai' is Grok TTS.
|
||||
'tts.provider': [
|
||||
'edge',
|
||||
'elevenlabs',
|
||||
'openai',
|
||||
'xai',
|
||||
'minimax',
|
||||
'mistral',
|
||||
'gemini',
|
||||
'neutts',
|
||||
'kittentts',
|
||||
'piper'
|
||||
],
|
||||
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe'],
|
||||
'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'],
|
||||
'tts.openai.model': ['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd'],
|
||||
'tts.elevenlabs.model_id': ['eleven_multilingual_v2', 'eleven_turbo_v2_5', 'eleven_flash_v2_5'],
|
||||
// NeuTTS local inference device.
|
||||
'tts.neutts.device': ['cpu', 'cuda', 'mps'],
|
||||
'updates.non_interactive_local_changes': ['stash', 'discard']
|
||||
}
|
||||
|
||||
@@ -268,7 +296,11 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
|
||||
backend: 'Execution Backend',
|
||||
timeout: 'Command Timeout',
|
||||
persistentShell: 'Persistent Shell',
|
||||
envPassthrough: 'Environment Passthrough'
|
||||
envPassthrough: 'Environment Passthrough',
|
||||
dockerImage: 'Docker Image',
|
||||
singularityImage: 'Singularity Image',
|
||||
modalImage: 'Modal Image',
|
||||
daytonaImage: 'Daytona Image'
|
||||
},
|
||||
fileReadMaxChars: 'File Read Limit',
|
||||
toolOutput: {
|
||||
@@ -309,6 +341,15 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
|
||||
model: 'Local Transcription Model',
|
||||
language: 'Transcription Language'
|
||||
},
|
||||
openai: {
|
||||
model: 'OpenAI STT Model'
|
||||
},
|
||||
groq: {
|
||||
model: 'Groq STT Model'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral STT Model'
|
||||
},
|
||||
elevenlabs: {
|
||||
modelId: 'ElevenLabs STT Model',
|
||||
languageCode: 'ElevenLabs Language',
|
||||
@@ -328,6 +369,33 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
|
||||
elevenlabs: {
|
||||
voiceId: 'ElevenLabs Voice',
|
||||
modelId: 'ElevenLabs Model'
|
||||
},
|
||||
xai: {
|
||||
voiceId: 'xAI (Grok) Voice',
|
||||
language: 'xAI Language'
|
||||
},
|
||||
minimax: {
|
||||
model: 'MiniMax TTS Model',
|
||||
voiceId: 'MiniMax Voice'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral TTS Model',
|
||||
voiceId: 'Mistral Voice'
|
||||
},
|
||||
gemini: {
|
||||
model: 'Gemini TTS Model',
|
||||
voice: 'Gemini Voice'
|
||||
},
|
||||
neutts: {
|
||||
model: 'NeuTTS Model',
|
||||
device: 'NeuTTS Device'
|
||||
},
|
||||
kittentts: {
|
||||
model: 'KittenTTS Model',
|
||||
voice: 'KittenTTS Voice'
|
||||
},
|
||||
piper: {
|
||||
voice: 'Piper Voice'
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
@@ -375,7 +443,11 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
|
||||
terminal: {
|
||||
cwd: 'Default project folder for tool and terminal work.',
|
||||
persistentShell: 'Keep shell state between commands when the backend supports it.',
|
||||
envPassthrough: 'Environment variables to pass into tool execution.'
|
||||
envPassthrough: 'Environment variables to pass into tool execution.',
|
||||
dockerImage: 'Container image used when the execution backend is Docker.',
|
||||
singularityImage: 'Image used when the execution backend is Singularity.',
|
||||
modalImage: 'Image used when the execution backend is Modal.',
|
||||
daytonaImage: 'Image used when the execution backend is Daytona.'
|
||||
},
|
||||
codeExecution: {
|
||||
mode: 'How strictly code execution is scoped to the current project.'
|
||||
@@ -404,6 +476,15 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
|
||||
voice: {
|
||||
autoTts: 'Automatically speak assistant responses.'
|
||||
},
|
||||
tts: {
|
||||
xai: {
|
||||
voiceId: 'xAI voice ID (e.g. eve) or a custom voice ID.',
|
||||
language: 'Spoken language code, e.g. en.'
|
||||
},
|
||||
neutts: {
|
||||
device: 'Local inference device for NeuTTS.'
|
||||
}
|
||||
},
|
||||
stt: {
|
||||
enabled: 'Enable local or provider-backed speech transcription.',
|
||||
elevenlabs: {
|
||||
@@ -495,8 +576,24 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'tts.openai.voice',
|
||||
'tts.elevenlabs.voice_id',
|
||||
'tts.elevenlabs.model_id',
|
||||
'tts.xai.voice_id',
|
||||
'tts.xai.language',
|
||||
'tts.minimax.model',
|
||||
'tts.minimax.voice_id',
|
||||
'tts.mistral.model',
|
||||
'tts.mistral.voice_id',
|
||||
'tts.gemini.model',
|
||||
'tts.gemini.voice',
|
||||
'tts.neutts.model',
|
||||
'tts.neutts.device',
|
||||
'tts.kittentts.model',
|
||||
'tts.kittentts.voice',
|
||||
'tts.piper.voice',
|
||||
'stt.local.model',
|
||||
'stt.local.language',
|
||||
'stt.openai.model',
|
||||
'stt.groq.model',
|
||||
'stt.mistral.model',
|
||||
'stt.elevenlabs.model_id',
|
||||
'stt.elevenlabs.language_code',
|
||||
'stt.elevenlabs.tag_audio_events',
|
||||
@@ -513,6 +610,10 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'toolsets',
|
||||
'terminal.backend',
|
||||
'terminal.timeout',
|
||||
'terminal.docker_image',
|
||||
'terminal.singularity_image',
|
||||
'terminal.modal_image',
|
||||
'terminal.daytona_image',
|
||||
'tool_output.max_bytes',
|
||||
'tool_output.max_lines',
|
||||
'tool_output.max_line_length',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
|
||||
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
describe('defineFieldCopy', () => {
|
||||
@@ -135,4 +135,38 @@ describe('settings helpers', () => {
|
||||
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enumOptionsFor — backend selector dropdowns', () => {
|
||||
const config: HermesConfigRecord = {}
|
||||
|
||||
it('renders a dropdown for the TTS provider including xAI (Grok)', () => {
|
||||
const opts = enumOptionsFor('tts.provider', 'edge', config)
|
||||
expect(opts).toBeDefined()
|
||||
expect(opts).toContain('xai')
|
||||
expect(opts).toContain('edge')
|
||||
expect(opts).toContain('elevenlabs')
|
||||
})
|
||||
|
||||
it('renders a dropdown for the STT provider including xAI (Grok)', () => {
|
||||
const opts = enumOptionsFor('stt.provider', 'local', config)
|
||||
expect(opts).toEqual(['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'])
|
||||
})
|
||||
|
||||
it('renders dropdowns for per-backend model/device sub-fields', () => {
|
||||
expect(enumOptionsFor('stt.openai.model', 'whisper-1', config)).toContain('gpt-4o-transcribe')
|
||||
expect(enumOptionsFor('tts.openai.model', 'gpt-4o-mini-tts', config)).toContain('tts-1-hd')
|
||||
expect(enumOptionsFor('tts.neutts.device', 'cpu', config)).toEqual(['cpu', 'cuda', 'mps'])
|
||||
})
|
||||
|
||||
it('renders a dropdown for the terminal execution backend', () => {
|
||||
const opts = enumOptionsFor('terminal.backend', 'local', config)
|
||||
expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'])
|
||||
})
|
||||
|
||||
it('appends a hand-typed value not in the known list so it stays selected', () => {
|
||||
const opts = enumOptionsFor('tts.provider', 'my-custom-command-tts', config)
|
||||
expect(opts).toContain('my-custom-command-tts')
|
||||
expect(opts).toContain('xai')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +17,11 @@ 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 {
|
||||
@@ -44,7 +44,7 @@ import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } fr
|
||||
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
|
||||
@@ -105,22 +105,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)
|
||||
|
||||
@@ -333,7 +350,7 @@ export function useStatusbarItems({
|
||||
<Zap className="size-3.5 shrink-0 opacity-70" />
|
||||
),
|
||||
id: 'yolo',
|
||||
onSelect: () => void toggleYolo(),
|
||||
onSelect: modifiers => void toggleYolo(modifiers),
|
||||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||
variant: 'action'
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
onClick={event => {
|
||||
if (item.to) {
|
||||
navigate(item.to)
|
||||
}
|
||||
|
||||
item.onSelect?.()
|
||||
item.onSelect?.({ shiftKey: event.shiftKey })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface ImageAttachResponse {
|
||||
path?: string
|
||||
text?: string
|
||||
message?: string
|
||||
// Returned by the byte-upload variant (image.attach_bytes) used in remote mode.
|
||||
count?: number
|
||||
bytes?: number
|
||||
name?: string
|
||||
width?: number
|
||||
height?: number
|
||||
token_estimate?: number
|
||||
}
|
||||
|
||||
export interface ImageDetachResponse {
|
||||
@@ -91,4 +98,9 @@ export interface ClientSessionState {
|
||||
/** A blocking clarify prompt is waiting on the user for this session. Drives
|
||||
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
|
||||
needsInput: boolean
|
||||
/** Epoch ms the current turn started, or null when idle. Per-session so a
|
||||
* background turn's elapsed timer keeps counting while another session is
|
||||
* focused, and switching sessions doesn't zero a still-running turn's clock.
|
||||
* The global $turnStartedAt mirrors whichever session is currently viewed. */
|
||||
turnStartedAt: number | null
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
@@ -327,25 +328,32 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
|
||||
* messages render after the backend embeds the data URL, so the UX is stable
|
||||
* across initial send and refresh. */
|
||||
const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
|
||||
const remote = /^(?:https?|data):/i.test(id)
|
||||
const [src, setSrc] = useState<string | null>(remote ? id : null)
|
||||
const isUrl = /^(?:https?|data):/i.test(id)
|
||||
const [src, setSrc] = useState<string | null>(isUrl ? id : null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (remote || !id) {
|
||||
if (isUrl || !id) {
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
void window.hermesDesktop
|
||||
?.readFileDataUrl(id)
|
||||
.then(url => alive && setSrc(url))
|
||||
|
||||
// Remote gateway: the image lives on the gateway's disk, not ours — fetch
|
||||
// it over the authenticated API. Local: read it straight off this disk.
|
||||
const load =
|
||||
window.hermesDesktop && isRemoteGateway()
|
||||
? gatewayMediaDataUrl(id)
|
||||
: window.hermesDesktop?.readFileDataUrl(id)
|
||||
|
||||
void Promise.resolve(load)
|
||||
.then(url => alive && url && setSrc(url))
|
||||
.catch(() => alive && setFailed(true))
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [id, remote])
|
||||
}, [id, isUrl])
|
||||
|
||||
if (failed) {
|
||||
return <DirectiveChip id={id} label={label} type="image" />
|
||||
|
||||
@@ -172,4 +172,33 @@ describe('preprocessMarkdown', () => {
|
||||
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not swallow trailing emphasis asterisks into an autolinked url', () => {
|
||||
const input = '**PR opened: https://github.com/NousResearch/hermes-agent/pull/12345**'
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
// The URL is autolinked WITHOUT the trailing `**` glued into the href,
|
||||
// and the bold emphasis run stays intact so it renders as bold + a link.
|
||||
expect(output).toContain('<https://github.com/NousResearch/hermes-agent/pull/12345>')
|
||||
expect(output).not.toContain('pull/12345**>')
|
||||
expect(output).not.toContain('12345*')
|
||||
})
|
||||
|
||||
it('stops an autolinked url at mid-string bold markers', () => {
|
||||
const input = 'See https://github.com/foo/bar**bold** for details.'
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('<https://github.com/foo/bar>')
|
||||
expect(output).toContain('**bold**')
|
||||
})
|
||||
|
||||
it('keeps underscores and tildes inside autolinked url paths', () => {
|
||||
const input = 'Docs at https://example.com/a_b/c~d/page'
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('<https://example.com/a_b/c~d/page>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,8 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
gatewayMediaDataUrl,
|
||||
isRemoteGateway,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaName,
|
||||
@@ -51,6 +53,12 @@ async function mediaSrc(path: string): Promise<string> {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
// Remote gateway: the image lives on the gateway machine, so read it over the
|
||||
// authenticated API rather than this machine's disk.
|
||||
if (window.hermesDesktop && isRemoteGateway()) {
|
||||
return gatewayMediaDataUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
@@ -417,7 +425,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
|
||||
<table
|
||||
className={cn(
|
||||
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -430,7 +438,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
th: ({ className, ...props }: ComponentProps<'th'>) => (
|
||||
<th
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
|
||||
'whitespace-nowrap px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -489,7 +489,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('keeps sticky-bottom armed through viewport height changes during streaming', async () => {
|
||||
it('does not follow streaming content growth even while parked at the bottom', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
@@ -508,6 +508,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
await wait(80)
|
||||
|
||||
// Park the user at the bottom of the current content.
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
@@ -520,6 +521,9 @@ describe('assistant-ui streaming renderer', () => {
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
// Content grows as tokens stream in. Streaming auto-follow is removed, so
|
||||
// the viewport must NOT chase the new bottom — it stays where the user
|
||||
// last left it.
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
@@ -529,7 +533,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(1_200)
|
||||
expect(viewport.scrollTop).toBe(760)
|
||||
})
|
||||
|
||||
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
|
||||
@@ -566,7 +570,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('keeps following final code-highlight growth when a run completes at bottom', async () => {
|
||||
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
@@ -588,10 +592,13 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
await wait(650)
|
||||
|
||||
// Completion re-measures (Shiki highlight) and grows the content. The
|
||||
// post-run bottom lock is removed, so the viewport stays put instead of
|
||||
// snapping to the new bottom.
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(1_700)
|
||||
expect(viewport.scrollTop).toBe(800)
|
||||
})
|
||||
|
||||
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
const POST_RUN_BOTTOM_LOCK_MS = 1_200
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
@@ -265,8 +264,27 @@ function useThreadScrollAnchor({
|
||||
return
|
||||
}
|
||||
|
||||
// Already parked at the bottom: writing `scrollTop` is a no-op and the
|
||||
// browser fires NO scroll event, so arming the programmatic gate here would
|
||||
// leave it permanently set. Repeated pins (streaming heartbeats, the
|
||||
// post-run lock loop) then accumulate the gate, and the next genuine user
|
||||
// scroll-up is misread as one of our programmatic scrolls — re-arming
|
||||
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
|
||||
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the disarm gate across the scroll event the next line will fire.
|
||||
programmaticScrollPendingRef.current += 1
|
||||
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
|
||||
// single scroll event, so a counter > 1 can never drain and would swallow a
|
||||
// later real user scroll.
|
||||
programmaticScrollPendingRef.current = 1
|
||||
scrollElementToBottom(el)
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
@@ -369,51 +387,15 @@ function useThreadScrollAnchor({
|
||||
}
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// Follow content growth (streaming, item measurements, loading indicator)
|
||||
// while armed. During fast streaming the ResizeObserver can fire many
|
||||
// times per frame as Streamdown re-tokenizes; coalesce to one pin per
|
||||
// animation frame so we don't run the scroll-event/re-pin chain
|
||||
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
|
||||
// token.
|
||||
useEffect(() => {
|
||||
if (!enabled || !isRunning) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let pinRafScheduled = false
|
||||
|
||||
const schedulePin = () => {
|
||||
if (pinRafScheduled || !stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pinRafScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
pinRafScheduled = false
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(schedulePin)
|
||||
|
||||
// Observe ONLY the content (firstElementChild), not the scroller `el`
|
||||
// itself. Resizes of the viewport/scroller (window resize, devtools
|
||||
// panel toggle) shouldn't trigger a pin — only content growth should.
|
||||
if (el.firstElementChild) {
|
||||
observer.observe(el.firstElementChild)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
|
||||
// Intentionally NO streaming auto-follow. Earlier builds ran a
|
||||
// ResizeObserver here that re-pinned the viewport to the bottom on every
|
||||
// content growth while a turn was running, so the chat tracked tokens as
|
||||
// they streamed. That behavior is removed by request: once a turn is in
|
||||
// flight the viewport stays exactly where the user left it. The viewport
|
||||
// is still moved to the bottom ONCE per user submit / new turn / session
|
||||
// change (see the layout effect and the session-change effect below) so a
|
||||
// freshly submitted message lands in view — but it does not chase the
|
||||
// stream afterward.
|
||||
|
||||
// Jump to bottom on session change OR when an empty thread first gets
|
||||
// content. Both share the same intent and the same effect.
|
||||
@@ -429,22 +411,21 @@ function useThreadScrollAnchor({
|
||||
}
|
||||
}, [enabled, groupCount, jumpToBottom, sessionKey])
|
||||
|
||||
// Pre-paint pin: when groupCount increases while armed (optimistic user
|
||||
// message insert, streaming assistant turn arriving, etc.), pin BEFORE
|
||||
// the browser commits the layout to screen. Using useLayoutEffect rather
|
||||
// than useEffect so this runs synchronously after React commits the DOM
|
||||
// mutation but before the browser paints. Without this, there's a ~50ms
|
||||
// visual window where the new message sits below the fold while we wait
|
||||
// for the ResizeObserver / scroll event chain to fire and re-pin.
|
||||
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
|
||||
// from the user submit or assistant turn start), pin BEFORE the browser
|
||||
// commits the layout to screen. Using useLayoutEffect rather than useEffect
|
||||
// so this runs synchronously after React commits the DOM mutation but before
|
||||
// the browser paints. Without this, there's a ~50ms visual window where the
|
||||
// new message sits below the fold.
|
||||
//
|
||||
// We pin TWICE in this critical path — once synchronously, then once on
|
||||
// the next rAF. The second pin catches the case where React mounts the
|
||||
// new message in the second commit (after our layout effect ran), which
|
||||
// grows scrollHeight again; without the rAF pin the user briefly sees a
|
||||
// ~15 px gap below the new message until the RO catches up. Streaming
|
||||
// tokens use the rate-limited RO path only; only the group-count change
|
||||
// (which fires once per user submit / new turn arrival) pays for the
|
||||
// extra pin.
|
||||
// ~15 px gap below the new message. This fires once per user submit / new
|
||||
// turn arrival — it is NOT streaming-token follow (that path is removed
|
||||
// above), so a turn that streams a long response after this initial jump
|
||||
// will not chase the bottom.
|
||||
const prevGroupCountForLayoutRef = useRef(groupCount)
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -468,45 +449,17 @@ function useThreadScrollAnchor({
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
// Completion swaps streaming placeholders/plain code for final rendered DOM
|
||||
// (notably Shiki-highlighted code). Keep following the bottom briefly after
|
||||
// `isRunning` flips false so that final measurement pass cannot strand the
|
||||
// viewport near the top of a large code block.
|
||||
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
|
||||
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
|
||||
// chase final Shiki re-highlight measurement. With streaming follow gone,
|
||||
// re-pinning at completion would yank the viewport back to the bottom even
|
||||
// though the user is reading earlier content — the opposite of what's
|
||||
// wanted. The one-time submit / new-turn jump already covers landing a
|
||||
// fresh message in view.
|
||||
const prevIsRunningForLayoutRef = useRef(isRunning)
|
||||
useLayoutEffect(() => {
|
||||
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
|
||||
prevIsRunningForLayoutRef.current = isRunning
|
||||
|
||||
if (!enabled || !finishedRun || !stickyBottomRef.current) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
|
||||
let lockRaf: number | null = null
|
||||
|
||||
const lockFrame = () => {
|
||||
lockRaf = null
|
||||
|
||||
if (!stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pinToBottom()
|
||||
|
||||
if (performance.now() < lockUntil) {
|
||||
lockRaf = requestAnimationFrame(lockFrame)
|
||||
}
|
||||
}
|
||||
|
||||
pinToBottom()
|
||||
lockRaf = requestAnimationFrame(lockFrame)
|
||||
|
||||
return () => {
|
||||
if (lockRaf !== null) {
|
||||
cancelAnimationFrame(lockRaf)
|
||||
}
|
||||
}
|
||||
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
|
||||
}, [isRunning])
|
||||
|
||||
useAuiEvent('thread.runStart', jumpToBottom)
|
||||
}
|
||||
|
||||
@@ -150,10 +150,7 @@ export const Thread: FC<{
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
<div
|
||||
className="flex min-h-0 w-full flex-col items-center justify-center"
|
||||
style={{ paddingBottom: 'var(--composer-measured-height)' }}
|
||||
>
|
||||
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
|
||||
<Intro {...intro} />
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -470,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
s =>
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex))
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
// A reasoning group with no actual text is pure noise — drop the whole
|
||||
|
||||
@@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
|
||||
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-0.5 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
|
||||
data-slot="aui_intro"
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
<p
|
||||
aria-label={WORDMARK}
|
||||
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
|
||||
className="fit-text mx-auto mb-1 w-[calc(100%-1rem)] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={{ '--fit-min': '2.75rem' } as CSSProperties}
|
||||
>
|
||||
<span>
|
||||
<span>{WORDMARK}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { PaneShellContextValue, PaneSlot } from './context'
|
||||
export { PaneShellContext } from './context'
|
||||
export { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
|
||||
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -31,6 +32,12 @@ export interface PaneProps {
|
||||
defaultOpen?: boolean
|
||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||
disabled?: boolean
|
||||
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
|
||||
forceCollapsed?: boolean
|
||||
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
|
||||
hoverReveal?: boolean
|
||||
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
|
||||
onOverlayActiveChange?: (overlayActive: boolean) => void
|
||||
id: string
|
||||
maxWidth?: WidthValue
|
||||
minWidth?: WidthValue
|
||||
@@ -53,6 +60,7 @@ export interface PaneShellProps {
|
||||
interface CollectedPane {
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
forceCollapsed: boolean
|
||||
id: string
|
||||
resizable: boolean
|
||||
side: PaneSide
|
||||
@@ -62,6 +70,22 @@ interface CollectedPane {
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
||||
|
||||
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
|
||||
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
|
||||
const HOVER_REVEAL_SLIDE_MS = 220
|
||||
const HOVER_REVEAL_ENTER_DELAY_MS = 130
|
||||
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area.
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 6
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
@@ -110,6 +134,7 @@ function collectPanes(children: ReactNode) {
|
||||
const entry: CollectedPane = {
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
forceCollapsed: props.forceCollapsed ?? false,
|
||||
id: props.id,
|
||||
resizable: props.resizable ?? false,
|
||||
side: props.side,
|
||||
@@ -124,7 +149,7 @@ function collectPanes(children: ReactNode) {
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
const open = !pane.disabled && stateOpen
|
||||
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
|
||||
|
||||
if (!open) {
|
||||
return { open: false, track: '0px' }
|
||||
@@ -193,14 +218,29 @@ export function Pane({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
disabled = false,
|
||||
hoverReveal = false,
|
||||
id,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
resizable = false
|
||||
onOverlayActiveChange,
|
||||
resizable = false,
|
||||
width
|
||||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const paneStates = useStore($paneStates)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
|
||||
const [forced, setForced] = useState(false)
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
const side = slot?.side ?? 'left'
|
||||
// Collapsed + hoverReveal: float the pane contents over the main column on
|
||||
// hover/focus instead of hiding them. Honors any persisted resize width.
|
||||
const overlayActive = !open && hoverReveal && !disabled
|
||||
const override = resizable ? paneStates[id]?.widthOverride : undefined
|
||||
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {
|
||||
@@ -211,12 +251,34 @@ export function Pane({
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
|
||||
// a collapsed overlay (reopened / widened).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !overlayActive) {
|
||||
setForced(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onToggle = (e: Event) => {
|
||||
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
|
||||
setForced(v => !v)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
|
||||
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
}, [id, overlayActive])
|
||||
|
||||
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
|
||||
useEffect(() => {
|
||||
onOverlayActiveChange?.(overlayActive)
|
||||
}, [onOverlayActiveChange, overlayActive])
|
||||
|
||||
const canResize = open && resizable
|
||||
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
||||
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
||||
const side = slot?.side ?? 'left'
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -273,6 +335,58 @@ export function Pane({
|
||||
return null
|
||||
}
|
||||
|
||||
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
|
||||
// thin edge trigger + the floating panel (both absolute, escaping the zero
|
||||
// box). group-hover (or data-forced from the keyboard) drives the slide; the
|
||||
// enter-delay is the hover-intent gate. No JS pointer math.
|
||||
if (overlayActive) {
|
||||
const edge = side === 'left' ? 'left' : 'right'
|
||||
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
|
||||
data-forced={forced ? '' : undefined}
|
||||
data-pane-hover-reveal={forced ? 'open' : 'closed'}
|
||||
data-pane-id={id}
|
||||
data-pane-open="false"
|
||||
data-pane-side={side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
||||
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
|
||||
/>
|
||||
|
||||
{/* Keyed on side so flipping panes remounts off-screen on the new edge
|
||||
instead of transitioning the transform across the viewport. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
|
||||
offscreen,
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
|
||||
)}
|
||||
key={edge}
|
||||
style={
|
||||
{
|
||||
[edge]: 0,
|
||||
width: overlayWidth,
|
||||
'--reveal-shadow': HOVER_REVEAL_SHADOW,
|
||||
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
|
||||
transitionTimingFunction: HOVER_REVEAL_EASE,
|
||||
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
|
||||
Vendored
+7
@@ -7,6 +7,13 @@ declare global {
|
||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||
// profile's backend from the pool.
|
||||
getConnection: (profile?: string | null) => Promise<HermesConnection>
|
||||
// Reconnect-after-wake recovery: liveness-probe the cached PRIMARY backend
|
||||
// and drop it if a remote one has gone unreachable, so the next
|
||||
// getConnection() rebuilds a reachable descriptor instead of the renderer
|
||||
// re-dialing a dead remote forever. No-op for local backends (they
|
||||
// self-heal via the child 'exit' handler). `rebuilt` is true when a stale
|
||||
// remote cache was dropped.
|
||||
revalidateConnection: () => Promise<{ ok: boolean; rebuilt: boolean }>
|
||||
// Keepalive: mark a pool profile backend as recently used so the idle
|
||||
// reaper spares it while its chat is active.
|
||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { listAllProfileSessions, listSessions } from './hermes'
|
||||
|
||||
const emptySessionsResponse = {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
sessions: [],
|
||||
total: 0
|
||||
}
|
||||
|
||||
describe('Hermes REST session helpers', () => {
|
||||
let api: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
api = vi.fn().mockResolvedValue(emptySessionsResponse)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { api }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
})
|
||||
|
||||
it('uses a longer timeout for the single-profile session list', async () => {
|
||||
await listSessions(50, 1)
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a longer timeout for the all-profile session list', async () => {
|
||||
await listAllProfileSessions(50, 1)
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
} from '@/types/hermes'
|
||||
|
||||
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
|
||||
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
export type {
|
||||
ActionResponse,
|
||||
@@ -136,7 +137,8 @@ export async function listSessions(
|
||||
order: 'created' | 'recent' = 'recent'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -176,7 +178,8 @@ export async function listAllProfileSessions(
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path:
|
||||
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1463,8 +1463,8 @@ export const en: Translations = {
|
||||
contextUsage: 'Context usage',
|
||||
session: 'Session',
|
||||
runtimeSessionElapsed: 'Runtime session elapsed',
|
||||
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off.',
|
||||
yoloOff: 'YOLO off — click to auto-approve dangerous commands.',
|
||||
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off. Shift+click toggles it globally.',
|
||||
yoloOff: 'YOLO off — click to auto-approve dangerous commands. Shift+click toggles it globally.',
|
||||
modelNone: 'none',
|
||||
noModel: 'no model',
|
||||
switchModel: 'Switch model',
|
||||
|
||||
@@ -239,7 +239,11 @@ export const ja = defineLocale({
|
||||
backend: '実行バックエンド',
|
||||
timeout: 'コマンドタイムアウト',
|
||||
persistentShell: '永続シェル',
|
||||
envPassthrough: '環境変数の引き継ぎ'
|
||||
envPassthrough: '環境変数の引き継ぎ',
|
||||
dockerImage: 'Docker イメージ',
|
||||
singularityImage: 'Singularity イメージ',
|
||||
modalImage: 'Modal イメージ',
|
||||
daytonaImage: 'Daytona イメージ'
|
||||
},
|
||||
fileReadMaxChars: 'ファイル読み取り上限',
|
||||
toolOutput: {
|
||||
@@ -280,6 +284,15 @@ export const ja = defineLocale({
|
||||
model: 'ローカル文字起こしモデル',
|
||||
language: '文字起こし言語'
|
||||
},
|
||||
openai: {
|
||||
model: 'OpenAI STT モデル'
|
||||
},
|
||||
groq: {
|
||||
model: 'Groq STT モデル'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral STT モデル'
|
||||
},
|
||||
elevenlabs: {
|
||||
modelId: 'ElevenLabs STT モデル',
|
||||
languageCode: 'ElevenLabs 言語',
|
||||
@@ -299,6 +312,33 @@ export const ja = defineLocale({
|
||||
elevenlabs: {
|
||||
voiceId: 'ElevenLabs 音声',
|
||||
modelId: 'ElevenLabs モデル'
|
||||
},
|
||||
xai: {
|
||||
voiceId: 'xAI (Grok) 音声',
|
||||
language: 'xAI 言語'
|
||||
},
|
||||
minimax: {
|
||||
model: 'MiniMax TTS モデル',
|
||||
voiceId: 'MiniMax 音声'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral TTS モデル',
|
||||
voiceId: 'Mistral 音声'
|
||||
},
|
||||
gemini: {
|
||||
model: 'Gemini TTS モデル',
|
||||
voice: 'Gemini 音声'
|
||||
},
|
||||
neutts: {
|
||||
model: 'NeuTTS モデル',
|
||||
device: 'NeuTTS デバイス'
|
||||
},
|
||||
kittentts: {
|
||||
model: 'KittenTTS モデル',
|
||||
voice: 'KittenTTS 音声'
|
||||
},
|
||||
piper: {
|
||||
voice: 'Piper 音声'
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
@@ -1566,8 +1606,8 @@ export const ja = defineLocale({
|
||||
contextUsage: 'コンテキスト使用状況',
|
||||
session: 'セッション',
|
||||
runtimeSessionElapsed: 'ランタイムセッション経過時間',
|
||||
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。',
|
||||
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。',
|
||||
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。Shift+クリックで全体に切り替え。',
|
||||
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。Shift+クリックで全体に切り替え。',
|
||||
modelNone: 'なし',
|
||||
noModel: 'モデルなし',
|
||||
switchModel: 'モデルを切り替え',
|
||||
|
||||
@@ -233,7 +233,11 @@ export const zhHant = defineLocale({
|
||||
backend: '執行後端',
|
||||
timeout: '指令逾時',
|
||||
persistentShell: '持久化 Shell',
|
||||
envPassthrough: '環境變數傳遞'
|
||||
envPassthrough: '環境變數傳遞',
|
||||
dockerImage: 'Docker 映像',
|
||||
singularityImage: 'Singularity 映像',
|
||||
modalImage: 'Modal 映像',
|
||||
daytonaImage: 'Daytona 映像'
|
||||
},
|
||||
fileReadMaxChars: '檔案讀取上限',
|
||||
toolOutput: {
|
||||
@@ -274,6 +278,15 @@ export const zhHant = defineLocale({
|
||||
model: '本機轉寫模型',
|
||||
language: '轉寫語言'
|
||||
},
|
||||
openai: {
|
||||
model: 'OpenAI STT 模型'
|
||||
},
|
||||
groq: {
|
||||
model: 'Groq STT 模型'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral STT 模型'
|
||||
},
|
||||
elevenlabs: {
|
||||
modelId: 'ElevenLabs STT 模型',
|
||||
languageCode: 'ElevenLabs 語言',
|
||||
@@ -293,6 +306,33 @@ export const zhHant = defineLocale({
|
||||
elevenlabs: {
|
||||
voiceId: 'ElevenLabs 語音',
|
||||
modelId: 'ElevenLabs 模型'
|
||||
},
|
||||
xai: {
|
||||
voiceId: 'xAI (Grok) 語音',
|
||||
language: 'xAI 語言'
|
||||
},
|
||||
minimax: {
|
||||
model: 'MiniMax TTS 模型',
|
||||
voiceId: 'MiniMax 語音'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral TTS 模型',
|
||||
voiceId: 'Mistral 語音'
|
||||
},
|
||||
gemini: {
|
||||
model: 'Gemini TTS 模型',
|
||||
voice: 'Gemini 語音'
|
||||
},
|
||||
neutts: {
|
||||
model: 'NeuTTS 模型',
|
||||
device: 'NeuTTS 裝置'
|
||||
},
|
||||
kittentts: {
|
||||
model: 'KittenTTS 模型',
|
||||
voice: 'KittenTTS 語音'
|
||||
},
|
||||
piper: {
|
||||
voice: 'Piper 語音'
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
@@ -1527,8 +1567,8 @@ export const zhHant = defineLocale({
|
||||
contextUsage: '上下文使用量',
|
||||
session: '工作階段',
|
||||
runtimeSessionElapsed: '執行時工作階段已用時間',
|
||||
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。',
|
||||
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。',
|
||||
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。Shift+點擊可全域切換。',
|
||||
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。Shift+點擊可全域切換。',
|
||||
modelNone: '無',
|
||||
noModel: '無模型',
|
||||
switchModel: '切換模型',
|
||||
|
||||
@@ -311,7 +311,11 @@ export const zh: Translations = {
|
||||
backend: '执行后端',
|
||||
timeout: '命令超时',
|
||||
persistentShell: '持久化 Shell',
|
||||
envPassthrough: '环境变量透传'
|
||||
envPassthrough: '环境变量透传',
|
||||
dockerImage: 'Docker 镜像',
|
||||
singularityImage: 'Singularity 镜像',
|
||||
modalImage: 'Modal 镜像',
|
||||
daytonaImage: 'Daytona 镜像'
|
||||
},
|
||||
fileReadMaxChars: '文件读取上限',
|
||||
toolOutput: {
|
||||
@@ -352,6 +356,15 @@ export const zh: Translations = {
|
||||
model: '本地转写模型',
|
||||
language: '转写语言'
|
||||
},
|
||||
openai: {
|
||||
model: 'OpenAI STT 模型'
|
||||
},
|
||||
groq: {
|
||||
model: 'Groq STT 模型'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral STT 模型'
|
||||
},
|
||||
elevenlabs: {
|
||||
modelId: 'ElevenLabs STT 模型',
|
||||
languageCode: 'ElevenLabs 语言',
|
||||
@@ -371,6 +384,33 @@ export const zh: Translations = {
|
||||
elevenlabs: {
|
||||
voiceId: 'ElevenLabs 语音',
|
||||
modelId: 'ElevenLabs 模型'
|
||||
},
|
||||
xai: {
|
||||
voiceId: 'xAI (Grok) 语音',
|
||||
language: 'xAI 语言'
|
||||
},
|
||||
minimax: {
|
||||
model: 'MiniMax TTS 模型',
|
||||
voiceId: 'MiniMax 语音'
|
||||
},
|
||||
mistral: {
|
||||
model: 'Mistral TTS 模型',
|
||||
voiceId: 'Mistral 语音'
|
||||
},
|
||||
gemini: {
|
||||
model: 'Gemini TTS 模型',
|
||||
voice: 'Gemini 语音'
|
||||
},
|
||||
neutts: {
|
||||
model: 'NeuTTS 模型',
|
||||
device: 'NeuTTS 设备'
|
||||
},
|
||||
kittentts: {
|
||||
model: 'KittenTTS 模型',
|
||||
voice: 'KittenTTS 语音'
|
||||
},
|
||||
piper: {
|
||||
voice: 'Piper 语音'
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
@@ -1604,8 +1644,8 @@ export const zh: Translations = {
|
||||
contextUsage: '上下文用量',
|
||||
session: '会话',
|
||||
runtimeSessionElapsed: '运行时会话已用时间',
|
||||
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。',
|
||||
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。',
|
||||
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。Shift+点击可全局切换。',
|
||||
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。Shift+点击可全局切换。',
|
||||
modelNone: '无',
|
||||
noModel: '无模型',
|
||||
switchModel: '切换模型',
|
||||
|
||||
@@ -46,7 +46,8 @@ export function createClientSessionState(
|
||||
sawAssistantPayload: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~))/g
|
||||
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
|
||||
const RAW_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g
|
||||
// Bare-URL autolink matcher. The character classes EXCLUDE `*` so a URL that
|
||||
// abuts markdown emphasis with no separating space (e.g. `**label: https://x**`,
|
||||
// a very common LLM pattern) doesn't swallow the trailing `**` into the href.
|
||||
// `*` is never meaningful in a real URL path, and GFM's own autolink extension
|
||||
// likewise strips trailing emphasis/punctuation — so dropping it here is safe
|
||||
// and keeps the emphasis run intact. Other trailing punctuation is still peeled
|
||||
// off by the final `[^\s<>"'`*.,;:!?]` class.
|
||||
const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g
|
||||
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi
|
||||
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
|
||||
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
|
||||
|
||||
describe('isRemoteGateway', () => {
|
||||
afterEach(() => {
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
it('is false with no connection', () => {
|
||||
$connection.set(null)
|
||||
expect(isRemoteGateway()).toBe(false)
|
||||
})
|
||||
|
||||
it('is false in local mode', () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
expect(isRemoteGateway()).toBe(false)
|
||||
})
|
||||
|
||||
it('is true in remote mode', () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
expect(isRemoteGateway()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filePathFromMediaPath', () => {
|
||||
it('passes through a plain path', () => {
|
||||
expect(filePathFromMediaPath('/home/u/.hermes/images/a.png')).toBe('/home/u/.hermes/images/a.png')
|
||||
})
|
||||
|
||||
it('decodes a file:// URL with encoded characters', () => {
|
||||
expect(filePathFromMediaPath('file:///tmp/a%20b.png')).toBe('/tmp/a b.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gatewayMediaDataUrl', () => {
|
||||
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
|
||||
|
||||
beforeEach(() => {
|
||||
api.mockClear()
|
||||
vi.stubGlobal('window', { hermesDesktop: { api } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('requests the encoded gateway path and returns the data URL', async () => {
|
||||
const url = await gatewayMediaDataUrl('/home/u/.hermes/images/a b.png')
|
||||
|
||||
expect(url).toBe('data:image/png;base64,ZHVtbXk=')
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
path: '/api/media?path=%2Fhome%2Fu%2F.hermes%2Fimages%2Fa%20b.png'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
|
||||
|
||||
interface MediaInfo {
|
||||
@@ -89,6 +91,26 @@ export function filePathFromMediaPath(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// True when this desktop shell is wired to a remote gateway. Local media paths
|
||||
// then live on the gateway machine, not this disk, so we fetch them over the API.
|
||||
export function isRemoteGateway(): boolean {
|
||||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
// Fetch a gateway-local image as a data URL via the authenticated REST bridge.
|
||||
// Used in remote mode where readFileDataUrl (which reads THIS machine's disk)
|
||||
// can't see files the agent wrote on the gateway. Requires the gateway to
|
||||
// expose GET /api/media (hermes_cli/web_server.py).
|
||||
export async function gatewayMediaDataUrl(path: string): Promise<string> {
|
||||
const file = filePathFromMediaPath(path)
|
||||
|
||||
const result = await window.hermesDesktop!.api<{ data_url: string }>({
|
||||
path: `/api/media?path=${encodeURIComponent(file)}`
|
||||
})
|
||||
|
||||
return result.data_url
|
||||
}
|
||||
|
||||
export function mediaDisplayLabel(path: string): string {
|
||||
const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
|
||||
const kind = mediaKind(path)
|
||||
|
||||
@@ -52,6 +52,14 @@ describe('sessionMatchesSearch', () => {
|
||||
expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches sessions by source platform and aliases', () => {
|
||||
expect(sessionMatchesSearch(makeSession({ source: 'telegram' }), 'Telegram')).toBe(true)
|
||||
expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'WhatsApp')).toBe(true)
|
||||
expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'wa')).toBe(true)
|
||||
expect(sessionMatchesSearch(makeSession({ source: 'slack' }), 'slack')).toBe(true)
|
||||
expect(sessionMatchesSearch(makeSession({ source: 'bluebubbles' }), 'imessage')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match unrelated queries', () => {
|
||||
expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionTitle } from './chat-runtime'
|
||||
import { sessionSourceSearchTerms } from './session-source'
|
||||
|
||||
export function sessionMatchesSearch(session: SessionInfo, query: string): boolean {
|
||||
const needle = query.trim().toLowerCase()
|
||||
@@ -14,6 +15,7 @@ export function sessionMatchesSearch(session: SessionInfo, query: string): boole
|
||||
session._lineage_root_id ?? '',
|
||||
sessionTitle(session),
|
||||
session.preview ?? '',
|
||||
session.cwd ?? ''
|
||||
session.cwd ?? '',
|
||||
...sessionSourceSearchTerms(session.source)
|
||||
].some(value => value.toLowerCase().includes(needle))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
api_server: 'API',
|
||||
bluebubbles: 'iMessage',
|
||||
cli: 'CLI',
|
||||
codex: 'Codex',
|
||||
desktop: 'Desktop',
|
||||
discord: 'Discord',
|
||||
email: 'Email',
|
||||
gateway: 'Gateway',
|
||||
local: 'Local',
|
||||
matrix: 'Matrix',
|
||||
mattermost: 'Mattermost',
|
||||
qqbot: 'QQ',
|
||||
signal: 'Signal',
|
||||
slack: 'Slack',
|
||||
sms: 'SMS',
|
||||
telegram: 'Telegram',
|
||||
tui: 'TUI',
|
||||
webhook: 'Webhook',
|
||||
weixin: 'WeChat',
|
||||
whatsapp: 'WhatsApp',
|
||||
yuanbao: 'Yuanbao'
|
||||
}
|
||||
|
||||
const SOURCE_ALIASES: Record<string, string[]> = {
|
||||
bluebubbles: ['apple messages', 'imessage'],
|
||||
cli: ['terminal'],
|
||||
desktop: ['app', 'gui'],
|
||||
local: ['machine'],
|
||||
qqbot: ['qq'],
|
||||
telegram: ['tg'],
|
||||
tui: ['terminal'],
|
||||
weixin: ['wechat'],
|
||||
whatsapp: ['wa']
|
||||
}
|
||||
|
||||
export function normalizeSessionSource(source: null | string | undefined): string | null {
|
||||
const id = source?.trim().toLowerCase()
|
||||
|
||||
return id || null
|
||||
}
|
||||
|
||||
export function sessionSourceLabel(source: null | string | undefined): string | null {
|
||||
const id = normalizeSessionSource(source)
|
||||
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return SOURCE_LABELS[id] || id.replace(/[_-]+/g, ' ').replace(/\b\w/g, char => char.toUpperCase())
|
||||
}
|
||||
|
||||
export function sessionSourceSearchTerms(source: null | string | undefined): string[] {
|
||||
const id = normalizeSessionSource(source)
|
||||
const label = sessionSourceLabel(id)
|
||||
|
||||
if (!id) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [id, label ?? '', ...(SOURCE_ALIASES[id] ?? [])].filter(Boolean)
|
||||
}
|
||||
@@ -24,3 +24,27 @@ export async function setSessionYolo(
|
||||
|
||||
return active
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle GLOBAL YOLO (approval bypass) via gateway `config.set` with
|
||||
* `scope: 'global'`. This flips the persistent `approvals.mode` in config.yaml
|
||||
* between `off` (bypass on) and `manual` (bypass off), affecting every session,
|
||||
* the CLI, the TUI, and cron — and it survives restarts. Triggered by
|
||||
* Shift+clicking the status-bar zap.
|
||||
*/
|
||||
export async function setGlobalYolo(
|
||||
requestGateway: GatewayRequester,
|
||||
enabled: boolean
|
||||
): Promise<boolean> {
|
||||
const result = await requestGateway<{ value?: string }>('config.set', {
|
||||
key: 'yolo',
|
||||
scope: 'global',
|
||||
value: enabled ? '1' : '0'
|
||||
})
|
||||
|
||||
const active = result?.value === '1'
|
||||
|
||||
setYoloActive(active)
|
||||
|
||||
return active
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
|
||||
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
|
||||
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
|
||||
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
|
||||
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
@@ -53,7 +55,14 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
|
||||
})
|
||||
|
||||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY))
|
||||
export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
|
||||
// true the whole time it's a floating overlay (not just while shown) so the
|
||||
// consumer mounts contents off-screen, ready to slide. ChatSidebar mounts its
|
||||
// rows on `sidebarOpen || this`.
|
||||
export const $sidebarOverlayMounted = atom(false)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
// Cron-job sessions live in their own section below recents, collapsed by
|
||||
// default (it only renders at all when cron sessions exist) so the
|
||||
@@ -68,6 +77,8 @@ export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
|
||||
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
|
||||
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
|
||||
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
|
||||
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
|
||||
|
||||
@@ -116,6 +127,10 @@ export function setSidebarPinsOpen(open: boolean) {
|
||||
$sidebarPinsOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarOverlayMounted(mounted: boolean) {
|
||||
$sidebarOverlayMounted.set(mounted)
|
||||
}
|
||||
|
||||
export function setSidebarRecentsOpen(open: boolean) {
|
||||
$sidebarRecentsOpen.set(open)
|
||||
}
|
||||
@@ -128,6 +143,18 @@ export function setSidebarAgentsGrouped(grouped: boolean) {
|
||||
$sidebarAgentsGrouped.set(grouped)
|
||||
}
|
||||
|
||||
export function setSidebarSessionOrderIds(ids: string[]) {
|
||||
if (!arraysEqual($sidebarSessionOrderIds.get(), ids)) {
|
||||
$sidebarSessionOrderIds.set(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSidebarWorkspaceOrderIds(ids: string[]) {
|
||||
if (!arraysEqual($sidebarWorkspaceOrderIds.get(), ids)) {
|
||||
$sidebarWorkspaceOrderIds.set(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSidebarResizing(resizing: boolean) {
|
||||
$isSidebarResizing.set(resizing)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ModelOptionProvider } from '@/types/hermes'
|
||||
|
||||
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
|
||||
|
||||
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
|
||||
models,
|
||||
name: slug,
|
||||
slug
|
||||
})
|
||||
|
||||
describe('model visibility', () => {
|
||||
it('keeps newly configured providers visible when stored choices are stale', () => {
|
||||
const stored = new Set([modelVisibilityKey('copilot', 'claude-sonnet-4.6')])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, [
|
||||
provider('copilot', ['claude-sonnet-4.6']),
|
||||
provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
|
||||
])
|
||||
|
||||
expect(visible.has(modelVisibilityKey('copilot', 'claude-sonnet-4.6'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-add models from a provider that already has stored choices', () => {
|
||||
const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, [
|
||||
provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
|
||||
])
|
||||
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -104,5 +104,30 @@ export function effectiveVisibleKeys(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[]
|
||||
): Set<string> {
|
||||
return stored ?? defaultVisibleKeys(providers)
|
||||
if (!stored) {
|
||||
return defaultVisibleKeys(providers)
|
||||
}
|
||||
|
||||
if (stored.size === 0) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const next = new Set(stored)
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerPrefix = `${provider.slug}::`
|
||||
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
|
||||
|
||||
if (hasStoredProvider) {
|
||||
continue
|
||||
}
|
||||
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
||||
next.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
+17
-27
@@ -888,52 +888,42 @@ canvas {
|
||||
}
|
||||
|
||||
.fit-text {
|
||||
--fit-captured-length: initial;
|
||||
--fit-support-sentinel: var(--fit-captured-length, 9999px);
|
||||
|
||||
display: flex;
|
||||
font-size: var(--fit-text-min, 1rem);
|
||||
container-type: inline-size;
|
||||
--captured-length: initial;
|
||||
--support-sentinel: var(--captured-length, 9999px);
|
||||
}
|
||||
|
||||
.fit-text > [aria-hidden='true'] {
|
||||
.fit-text > [aria-hidden] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.fit-text > :not([aria-hidden='true']) {
|
||||
.fit-text > :not([aria-hidden]) {
|
||||
flex-grow: 1;
|
||||
container-type: inline-size;
|
||||
--captured-length: 100cqi;
|
||||
--available-space: var(--captured-length);
|
||||
|
||||
--fit-captured-length: 100cqi;
|
||||
--fit-available-space: var(--fit-captured-length);
|
||||
}
|
||||
|
||||
.fit-text > :not([aria-hidden='true']) > * {
|
||||
.fit-text > :not([aria-hidden]) > * {
|
||||
--fit-support-sentinel: inherit;
|
||||
--fit-captured-length: 100cqi;
|
||||
--fit-ratio: tan(atan2(var(--fit-available-space), var(--fit-available-space) - var(--fit-captured-length)));
|
||||
|
||||
display: block;
|
||||
inline-size: var(--available-space);
|
||||
line-height: var(--fit-text-line-height, 1);
|
||||
--support-sentinel: inherit;
|
||||
--captured-length: 100cqi;
|
||||
--ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length)));
|
||||
--font-size: clamp(
|
||||
var(--fit-text-min, 1em),
|
||||
1em * var(--ratio),
|
||||
var(--fit-text-max, infinity * 1px) - var(--support-sentinel)
|
||||
);
|
||||
font-size: var(--font-size);
|
||||
inline-size: var(--fit-available-space);
|
||||
font-size: clamp(var(--fit-min, 1em), 1em * var(--fit-ratio), var(--fit-max, infinity * 1px) - var(--fit-support-sentinel));
|
||||
}
|
||||
|
||||
@container (inline-size > 0) {
|
||||
.fit-text > :not([aria-hidden='true']) > * {
|
||||
.fit-text > :not([aria-hidden]) > * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@property --captured-length {
|
||||
syntax: '<length>';
|
||||
initial-value: 0px;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
@property --captured-length2 {
|
||||
@property --fit-captured-length {
|
||||
syntax: '<length>';
|
||||
initial-value: 0px;
|
||||
inherits: true;
|
||||
|
||||
@@ -6,6 +6,19 @@ import path from 'path'
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
css: {
|
||||
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
|
||||
// `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and
|
||||
// without this, Vite's `postcss-load-config` walks UP the filesystem
|
||||
// looking for a stray `postcss.config.*` / `tailwind.config.*`. The desktop
|
||||
// build runs from inside the user's home tree (e.g.
|
||||
// `C:\Users\<name>\AppData\Local\hermes\hermes-agent\apps\desktop`), so an
|
||||
// unrelated Tailwind v3 config higher up the tree gets picked up and
|
||||
// reprocesses our v4 stylesheet, failing the build with
|
||||
// "`@layer base` is used but no matching `@tailwind base` directive is
|
||||
// present." Pinning the config makes the build hermetic.
|
||||
postcss: { plugins: [] }
|
||||
},
|
||||
build: {
|
||||
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
|
||||
// default, and electron-builder can OOM scanning thousands of files.
|
||||
|
||||
+15
-1
@@ -72,6 +72,20 @@ model:
|
||||
#
|
||||
# max_tokens: 8192
|
||||
|
||||
# ── Custom request headers (optional) ─────────────────────────────────────
|
||||
#
|
||||
# default_headers: extra HTTP headers sent on every request to an
|
||||
# OpenAI-compatible endpoint. User values take precedence over the
|
||||
# provider/SDK defaults, so this is the supported way to override the
|
||||
# OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ...,
|
||||
# X-Stainless-*) when a custom provider sits behind a gateway/WAF that
|
||||
# rejects them — e.g. an upstream that returns "502 Upstream access
|
||||
# forbidden" for the SDK default User-Agent but accepts a plain one.
|
||||
# Applies on the OpenAI wire only (not native Anthropic / Bedrock).
|
||||
#
|
||||
# default_headers:
|
||||
# User-Agent: "curl/8.7.1"
|
||||
|
||||
# Named provider overrides (optional)
|
||||
# Use this for per-provider request timeouts, non-stream stale timeouts,
|
||||
# and per-model exceptions.
|
||||
@@ -871,7 +885,7 @@ delegation:
|
||||
max_iterations: 50 # Max tool-calling turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
|
||||
# WARNING: values above 10 multiply API cost linearly.
|
||||
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
|
||||
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
|
||||
@@ -17,6 +17,23 @@ Events:
|
||||
- command:* -- Any slash command executed (wildcard match)
|
||||
|
||||
Errors in hooks are caught and logged but never block the main pipeline.
|
||||
|
||||
Context dict passed to ``agent:start`` / ``agent:end`` handlers:
|
||||
platform -- source platform name (e.g. "telegram", "matrix", "slack")
|
||||
user_id -- platform user id of the sender
|
||||
chat_id -- platform chat id (group/DM identifier)
|
||||
thread_id -- Telegram forum-topic id / thread root id (string; empty
|
||||
when not in a thread / topic)
|
||||
chat_type -- "dm" | "group" | "forum" (empty if unknown)
|
||||
session_id -- Hermes session id
|
||||
message -- inbound message text (truncated to 500 chars)
|
||||
|
||||
``agent:end`` adds:
|
||||
response -- agent response text (truncated to 500 chars)
|
||||
|
||||
Handlers posting a follow-up into the same Telegram forum-topic should
|
||||
include ``message_thread_id=int(thread_id)`` when ``chat_type == "forum"``
|
||||
and ``thread_id`` is non-empty.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,29 @@ from gateway.platforms.base import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hermes_version() -> str:
|
||||
"""Return the hermes-agent version string, or "dev" if it can't be resolved.
|
||||
|
||||
Tries the installed package metadata first (authoritative for a pip/uv
|
||||
install), then the in-tree ``hermes_cli.__version__`` (covers editable /
|
||||
source checkouts where metadata may be stale or absent). Never raises —
|
||||
a version probe must not be able to break the health endpoint.
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("hermes-agent")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli import __version__
|
||||
|
||||
return __version__
|
||||
except Exception:
|
||||
return "dev"
|
||||
|
||||
|
||||
# Default settings
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8642
|
||||
@@ -688,6 +711,19 @@ except ImportError:
|
||||
_cron_resume = None
|
||||
_cron_trigger = None
|
||||
|
||||
# Defense-in-depth: mirror the agent-facing cronjob tool, which scans the
|
||||
# user-supplied prompt for exfiltration/injection payloads at create/update
|
||||
# time (tools/cronjob_tools.py). The REST cron endpoints are authenticated
|
||||
# (every handler runs _check_auth, and connect() refuses to start without
|
||||
# API_SERVER_KEY), so this is not the trust boundary — it's parity with the
|
||||
# tool path so a malicious prompt is rejected the same way regardless of
|
||||
# which surface created the job. Imported defensively: a missing scanner
|
||||
# must not disable the cron REST API.
|
||||
try:
|
||||
from tools.cronjob_tools import _scan_cron_prompt as _scan_cron_prompt
|
||||
except Exception: # pragma: no cover - scanner is optional hardening
|
||||
_scan_cron_prompt = None
|
||||
|
||||
|
||||
class APIServerAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
@@ -1034,7 +1070,9 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "hermes-agent"})
|
||||
return web.json_response(
|
||||
{"status": "ok", "platform": "hermes-agent", "version": _hermes_version()}
|
||||
)
|
||||
|
||||
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /health/detailed — rich status for cross-container dashboard probing.
|
||||
@@ -1049,6 +1087,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"platform": "hermes-agent",
|
||||
"version": _hermes_version(),
|
||||
"gateway_state": runtime.get("gateway_state"),
|
||||
"platforms": runtime.get("platforms", {}),
|
||||
"active_agents": runtime.get("active_agents", 0),
|
||||
@@ -1441,10 +1480,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if err:
|
||||
return err
|
||||
db = self._ensure_session_db()
|
||||
messages = db.get_messages(session_id)
|
||||
resolved_id = db.resolve_resume_session_id(session_id)
|
||||
messages = db.get_messages(resolved_id)
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"session_id": session_id,
|
||||
"session_id": resolved_id,
|
||||
"data": [self._message_response(m) for m in messages],
|
||||
})
|
||||
|
||||
@@ -3140,6 +3180,10 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return web.json_response(
|
||||
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
|
||||
)
|
||||
if prompt and _scan_cron_prompt is not None:
|
||||
scan_error = _scan_cron_prompt(prompt)
|
||||
if scan_error:
|
||||
return web.json_response({"error": scan_error}, status=400)
|
||||
if repeat is not None and (not isinstance(repeat, int) or repeat < 1):
|
||||
return web.json_response({"error": "Repeat must be a positive integer"}, status=400)
|
||||
|
||||
@@ -3205,6 +3249,10 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return web.json_response(
|
||||
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
|
||||
)
|
||||
if sanitized.get("prompt") and _scan_cron_prompt is not None:
|
||||
scan_error = _scan_cron_prompt(sanitized["prompt"])
|
||||
if scan_error:
|
||||
return web.json_response({"error": scan_error}, status=400)
|
||||
job = _cron_update(job_id, sanitized)
|
||||
if not job:
|
||||
return web.json_response({"error": "Job not found"}, status=404)
|
||||
|
||||
@@ -1792,7 +1792,14 @@ class BasePlatformAdapter(ABC):
|
||||
- Sending messages/responses
|
||||
- Handling media
|
||||
"""
|
||||
|
||||
|
||||
# Whether this platform renders triple-backtick fenced code blocks (i.e.
|
||||
# ``format_message`` translates/preserves markdown fences into a real code
|
||||
# block). Drives presentation choices like rendering a ``terminal`` tool
|
||||
# call's command as a ```bash block instead of a flat preview line.
|
||||
# Default False (plain-text platforms); markdown-rendering adapters set True.
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
self.config = config
|
||||
self.platform = platform
|
||||
|
||||
@@ -1409,6 +1409,8 @@ def check_feishu_requirements() -> bool:
|
||||
class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
supports_code_blocks = True # Feishu renders fenced code blocks
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
|
||||
CHAT_LOCK_MAX_SIZE: int = 1000
|
||||
@@ -2644,7 +2646,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if prompt_id is None:
|
||||
logger.debug("[Feishu] Card action missing update_prompt_id, ignoring")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
if prompt_id not in self._update_prompt_state:
|
||||
state = self._update_prompt_state.get(prompt_id)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
@@ -2655,12 +2658,33 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
|
||||
operator = getattr(event, "operator", None)
|
||||
open_id = str(getattr(operator, "open_id", "") or "")
|
||||
if not self._is_interactive_operator_authorized(open_id):
|
||||
sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
|
||||
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
|
||||
logger.warning("[Feishu] Unauthorized update prompt click by %s", open_id or "<unknown>")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
|
||||
expected_chat_id = str(state.get("chat_id", "") or "")
|
||||
if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
|
||||
logger.warning(
|
||||
"[Feishu] Update prompt callback chat mismatch for %s (expected=%s, got=%s)",
|
||||
prompt_id,
|
||||
expected_chat_id,
|
||||
callback_chat_id,
|
||||
)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
user_name = self._get_cached_sender_name(open_id) or open_id
|
||||
if not self._submit_on_loop(loop, self._resolve_update_prompt(prompt_id, answer, user_name)):
|
||||
if not self._submit_on_loop(
|
||||
loop,
|
||||
self._resolve_update_prompt(
|
||||
prompt_id,
|
||||
answer,
|
||||
user_name,
|
||||
open_id=open_id,
|
||||
chat_id=callback_chat_id,
|
||||
),
|
||||
):
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
if P2CardActionTriggerResponse is None:
|
||||
@@ -2711,12 +2735,38 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
|
||||
|
||||
async def _resolve_update_prompt(self, prompt_id: Any, answer: str, user_name: str) -> None:
|
||||
async def _resolve_update_prompt(
|
||||
self,
|
||||
prompt_id: Any,
|
||||
answer: str,
|
||||
user_name: str,
|
||||
*,
|
||||
open_id: str = "",
|
||||
chat_id: str = "",
|
||||
) -> None:
|
||||
"""Persist an update prompt answer for the detached update process."""
|
||||
state = self._update_prompt_state.pop(prompt_id, None)
|
||||
state = self._update_prompt_state.get(prompt_id)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
|
||||
return
|
||||
if open_id:
|
||||
sender_id = SimpleNamespace(open_id=open_id, user_id="")
|
||||
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
|
||||
logger.warning("[Feishu] Unauthorized update prompt click by %s for prompt %s", open_id, prompt_id)
|
||||
return
|
||||
expected_chat_id = str(state.get("chat_id", "") or "")
|
||||
if expected_chat_id and chat_id and expected_chat_id != chat_id:
|
||||
logger.warning(
|
||||
"[Feishu] Update prompt %s chat mismatch (expected=%s, got=%s)",
|
||||
prompt_id,
|
||||
expected_chat_id,
|
||||
chat_id,
|
||||
)
|
||||
return
|
||||
state = self._update_prompt_state.pop(prompt_id, None)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Update prompt %s already resolved while validating callback", prompt_id)
|
||||
return
|
||||
try:
|
||||
self._write_update_prompt_response(answer)
|
||||
logger.info(
|
||||
|
||||
@@ -420,6 +420,8 @@ class _CryptoStateStore:
|
||||
class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Gateway adapter for Matrix (any homeserver)."""
|
||||
|
||||
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
|
||||
+105
-3
@@ -317,6 +317,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
||||
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SLACK)
|
||||
@@ -2290,7 +2291,38 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not thread_ts and self._dm_top_level_threads_as_sessions():
|
||||
thread_ts = ts
|
||||
else:
|
||||
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
|
||||
# Channel message session scoping.
|
||||
#
|
||||
# Three cases:
|
||||
# (a) genuine thread reply → scope session per thread
|
||||
# (b) top-level, reply_in_thread=true (the default) →
|
||||
# legacy behaviour: each top-level message becomes its
|
||||
# own thread, so the UX still "replies in a thread"
|
||||
# and sessions are keyed per thread root
|
||||
# (c) top-level, reply_in_thread=false → scope one session
|
||||
# across the whole channel so context accumulates across
|
||||
# messages (#15421 bug 1)
|
||||
event_thread_ts_raw = event.get("thread_ts")
|
||||
# Align with ``is_thread_reply`` below — a ``thread_ts ==
|
||||
# ts`` payload (some thread-root shapes) is not a real reply
|
||||
# and must not prevent the shared-session path from taking
|
||||
# effect. Matching the same invariant here keeps the two
|
||||
# branches in sync even if Slack introduces new payload
|
||||
# variants (Copilot on #15464).
|
||||
if event_thread_ts_raw and event_thread_ts_raw != ts:
|
||||
thread_ts = event_thread_ts_raw
|
||||
elif self.config.extra.get("reply_in_thread", True):
|
||||
# Legacy default: treat ts as a synthetic thread root so
|
||||
# this top-level message gets its own session.
|
||||
thread_ts = ts
|
||||
else:
|
||||
# reply_in_thread=false: no thread key → session manager
|
||||
# groups by (platform, channel_id, None) and the channel
|
||||
# shares one conversation. reply_to_message_id at the
|
||||
# outbound side is already gated on ``thread_ts != ts``
|
||||
# so None here produces a non-threaded reply without
|
||||
# further changes.
|
||||
thread_ts = None
|
||||
|
||||
# In channels, respond if:
|
||||
# 0. Channel is in free_response_channels, OR require_mention is
|
||||
@@ -2797,6 +2829,55 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
def _is_interactive_user_authorized(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
channel_id: str = "",
|
||||
user_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Return whether a Slack interactive caller may perform gated actions."""
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
if not normalized_user_id:
|
||||
return False
|
||||
|
||||
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
|
||||
auth_fn = getattr(runner, "_is_user_authorized", None)
|
||||
if callable(auth_fn):
|
||||
try:
|
||||
from gateway.session import SessionSource
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id=str(channel_id or normalized_user_id),
|
||||
chat_type="dm" if str(channel_id or "").startswith("D") else "group",
|
||||
user_id=normalized_user_id,
|
||||
user_name=str(user_name).strip() if user_name else None,
|
||||
)
|
||||
return bool(auth_fn(source))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[Slack] Falling back to env-only interactive auth for user %s",
|
||||
normalized_user_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if os.getenv("SLACK_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
|
||||
return True
|
||||
|
||||
allowed_ids = set()
|
||||
platform_allowlist = os.getenv("SLACK_ALLOWED_USERS", "").strip()
|
||||
if platform_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip())
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
if allowed_ids:
|
||||
return "*" in allowed_ids or normalized_user_id in allowed_ids
|
||||
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
async def _handle_slash_confirm_action(self, ack, body, action) -> None:
|
||||
"""Handle a slash-confirm button click from Block Kit."""
|
||||
await ack()
|
||||
@@ -2808,9 +2889,19 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
channel_id = body.get("channel", {}).get("id", "")
|
||||
user_name = body.get("user", {}).get("name", "unknown")
|
||||
user_id = body.get("user", {}).get("id", "")
|
||||
if not self._is_interactive_user_authorized(
|
||||
user_id,
|
||||
channel_id=channel_id,
|
||||
user_name=user_name,
|
||||
):
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring",
|
||||
user_name, user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Authorization — reuse the exec-approval allowlist.
|
||||
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
|
||||
allowed_csv = "" # Interactive auth already ran above.
|
||||
if allowed_csv:
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and user_id not in allowed_ids:
|
||||
@@ -2917,10 +3008,21 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
user_name = body.get("user", {}).get("name", "unknown")
|
||||
user_id = body.get("user", {}).get("id", "")
|
||||
|
||||
if not self._is_interactive_user_authorized(
|
||||
user_id,
|
||||
channel_id=channel_id,
|
||||
user_name=user_name,
|
||||
):
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized approval click by %s (%s) - ignoring",
|
||||
user_name, user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Only authorized users may click approval buttons. Button clicks
|
||||
# bypass the normal message auth flow in gateway/run.py, so we must
|
||||
# check here as well.
|
||||
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
|
||||
allowed_csv = "" # Interactive auth already ran above.
|
||||
if allowed_csv:
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and user_id not in allowed_ids:
|
||||
|
||||
@@ -344,6 +344,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
# Telegram message limits
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
# When a chunk is near this limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
@@ -1142,7 +1143,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# gateway process is alive and reports "connected" but
|
||||
# no messages are received or sent.
|
||||
if self._polling_conflict_count < MAX_CONFLICT_RETRIES:
|
||||
loop = asyncio.get_event_loop()
|
||||
# We are inside a running coroutine, so the running loop is
|
||||
# guaranteed to exist. asyncio.get_event_loop() is deprecated
|
||||
# and raises "RuntimeError: There is no current event loop in
|
||||
# thread 'MainThread'" on Python 3.10+ when invoked from a
|
||||
# context without an attached loop (which can happen when PTB
|
||||
# dispatches this error callback). Use get_running_loop().
|
||||
loop = asyncio.get_running_loop()
|
||||
self._polling_error_task = loop.create_task(
|
||||
self._handle_polling_conflict(retry_err)
|
||||
)
|
||||
|
||||
+113
-2
@@ -1138,6 +1138,8 @@ async def qr_login(
|
||||
class WeixinAdapter(BasePlatformAdapter):
|
||||
"""Native Hermes adapter for Weixin personal accounts."""
|
||||
|
||||
supports_code_blocks = True # Weixin renders fenced code blocks
|
||||
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
|
||||
# WeChat does not support editing sent messages — streaming must use the
|
||||
@@ -1172,6 +1174,24 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
extra.get("send_chunk_retry_delay_seconds")
|
||||
or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0")
|
||||
)
|
||||
self._send_text_gate = asyncio.Lock()
|
||||
self._rate_limit_circuit_threshold = max(
|
||||
1,
|
||||
int(
|
||||
extra.get("rate_limit_circuit_threshold")
|
||||
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD", "1")
|
||||
),
|
||||
)
|
||||
self._rate_limit_circuit_window_seconds = float(
|
||||
extra.get("rate_limit_circuit_window_seconds")
|
||||
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS", "30.0")
|
||||
)
|
||||
self._rate_limit_circuit_open_seconds = float(
|
||||
extra.get("rate_limit_circuit_open_seconds")
|
||||
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS", "30.0")
|
||||
)
|
||||
self._rate_limit_circuit_until = 0.0
|
||||
self._rate_limit_events: List[float] = []
|
||||
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower()
|
||||
self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
|
||||
allow_from = extra.get("allow_from")
|
||||
@@ -1645,6 +1665,37 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,
|
||||
)
|
||||
|
||||
def _rate_limit_cooldown_remaining(self) -> float:
|
||||
return max(0.0, self._rate_limit_circuit_until - time.monotonic())
|
||||
|
||||
def _rate_limit_error(self) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
f"iLink sendmessage rate limited; cooldown active for {self._rate_limit_cooldown_remaining():.1f}s"
|
||||
)
|
||||
|
||||
def _open_rate_limit_circuit(self) -> None:
|
||||
if self._rate_limit_circuit_open_seconds <= 0:
|
||||
return
|
||||
self._rate_limit_circuit_until = max(
|
||||
self._rate_limit_circuit_until,
|
||||
time.monotonic() + self._rate_limit_circuit_open_seconds,
|
||||
)
|
||||
|
||||
def _record_rate_limit_event(self) -> bool:
|
||||
"""Record a genuine iLink rate limit and return True if breaker opened."""
|
||||
now = time.monotonic()
|
||||
window_start = now - self._rate_limit_circuit_window_seconds
|
||||
self._rate_limit_events = [ts for ts in self._rate_limit_events if ts >= window_start]
|
||||
self._rate_limit_events.append(now)
|
||||
if len(self._rate_limit_events) >= self._rate_limit_circuit_threshold:
|
||||
self._open_rate_limit_circuit()
|
||||
return self._rate_limit_cooldown_remaining() > 0
|
||||
return False
|
||||
|
||||
def _reset_rate_limit_circuit(self) -> None:
|
||||
self._rate_limit_events.clear()
|
||||
self._rate_limit_circuit_until = 0.0
|
||||
|
||||
async def _send_text_chunk(
|
||||
self,
|
||||
*,
|
||||
@@ -1660,9 +1711,28 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
degraded fallback, which keeps cron-initiated push messages working
|
||||
even when no user message has refreshed the session recently.
|
||||
"""
|
||||
async with self._send_text_gate:
|
||||
await self._send_text_chunk_locked(
|
||||
chat_id=chat_id,
|
||||
chunk=chunk,
|
||||
context_token=context_token,
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
async def _send_text_chunk_locked(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
chunk: str,
|
||||
context_token: Optional[str],
|
||||
client_id: str,
|
||||
) -> None:
|
||||
"""Send a text chunk while holding the adapter-wide outbound text gate."""
|
||||
last_error: Optional[Exception] = None
|
||||
retried_without_token = False
|
||||
for attempt in range(self._send_chunk_retries + 1):
|
||||
if self._rate_limit_cooldown_remaining() > 0:
|
||||
raise self._rate_limit_error()
|
||||
try:
|
||||
resp = await _send_message(
|
||||
self._send_session,
|
||||
@@ -1708,6 +1778,9 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
last_error = RuntimeError(
|
||||
f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}"
|
||||
)
|
||||
if self._record_rate_limit_event():
|
||||
last_error = self._rate_limit_error()
|
||||
break
|
||||
if attempt >= self._send_chunk_retries:
|
||||
break
|
||||
wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit
|
||||
@@ -1721,6 +1794,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
raise RuntimeError(
|
||||
f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}"
|
||||
)
|
||||
self._reset_rate_limit_circuit()
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
@@ -1808,10 +1882,47 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def _ensure_typing_ticket(self, chat_id: str) -> Optional[str]:
|
||||
"""Return a valid typing ticket, refreshing from getConfig if expired.
|
||||
|
||||
The iLink typing ticket has a 600-second TTL. When a long-running
|
||||
session exceeds that window the cached ticket evicts, and both
|
||||
``send_typing`` and ``stop_typing`` silently no-op — leaving the
|
||||
WeChat client stuck showing the typing indicator forever. This
|
||||
method transparently refreshes the ticket so the stop signal can
|
||||
always be delivered.
|
||||
"""
|
||||
ticket = self._typing_cache.get(chat_id)
|
||||
if ticket:
|
||||
return ticket
|
||||
if not self._send_session or not self._token:
|
||||
return None
|
||||
# Ticket expired or never fetched — refresh via getConfig.
|
||||
# Use the most recent context_token for this peer if available.
|
||||
context_token = self._token_store.get(self._account_id, chat_id)
|
||||
try:
|
||||
response = await _get_config(
|
||||
self._send_session,
|
||||
base_url=self._base_url,
|
||||
token=self._token,
|
||||
user_id=chat_id,
|
||||
context_token=context_token,
|
||||
)
|
||||
typing_ticket = str(response.get("typing_ticket") or "")
|
||||
if typing_ticket:
|
||||
self._typing_cache.set(chat_id, typing_ticket)
|
||||
return typing_ticket
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[%s] typing ticket refresh failed for %s: %s",
|
||||
self.name, _safe_id(chat_id), exc,
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
if not self._send_session or not self._token:
|
||||
return
|
||||
typing_ticket = self._typing_cache.get(chat_id)
|
||||
typing_ticket = await self._ensure_typing_ticket(chat_id)
|
||||
if not typing_ticket:
|
||||
return
|
||||
try:
|
||||
@@ -1829,7 +1940,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
if not self._send_session or not self._token:
|
||||
return
|
||||
typing_ticket = self._typing_cache.get(chat_id)
|
||||
typing_ticket = await self._ensure_typing_ticket(chat_id)
|
||||
if not typing_ticket:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -242,6 +242,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# WhatsApp message limits — practical UX limit, not protocol max.
|
||||
# WhatsApp allows ~65K but long messages are unreadable on mobile.
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
|
||||
DEFAULT_REPLY_PREFIX = "⚕ *Hermes Agent*\n────────────\n"
|
||||
|
||||
# Default bridge location relative to the hermes-agent install
|
||||
|
||||
@@ -120,6 +120,16 @@ AUTH_TIMEOUT_SECONDS = 10.0
|
||||
MAX_RECONNECT_ATTEMPTS = 100
|
||||
DEFAULT_SEND_TIMEOUT = 30.0 # WS biz request timeout
|
||||
|
||||
# Upper bound on the WS close handshake during teardown (#40383). The
|
||||
# websockets connection's own close_timeout (5s) blocks until the server
|
||||
# echoes the close frame; an idle/unresponsive server never replies, stalling
|
||||
# gateway shutdown by the full timeout. Bounding the close await here keeps
|
||||
# teardown fast — a responsive server completes the handshake in well under a
|
||||
# second, so this only caps the pathological hang. Also bounds the reconnect /
|
||||
# connect-failure cleanup paths that reuse _cleanup_ws(), where a graceful
|
||||
# close is unnecessary anyway (the socket is being discarded to redial).
|
||||
WS_CLOSE_TIMEOUT_S = 1.0
|
||||
|
||||
# Close codes that indicate permanent errors — do NOT reconnect.
|
||||
NO_RECONNECT_CLOSE_CODES = {4012, 4013, 4014, 4018, 4019, 4021}
|
||||
|
||||
@@ -3445,12 +3455,22 @@ class ConnectionManager:
|
||||
return False
|
||||
|
||||
async def _cleanup_ws(self) -> None:
|
||||
"""Close and clear the WebSocket connection."""
|
||||
"""Close and clear the WebSocket connection, bounded by
|
||||
``WS_CLOSE_TIMEOUT_S`` so an unresponsive server can't stall teardown
|
||||
(see the constant's definition for the full rationale)."""
|
||||
ws = self._ws
|
||||
self._ws = None
|
||||
if ws is not None:
|
||||
try:
|
||||
await ws.close()
|
||||
await asyncio.wait_for(ws.close(), timeout=WS_CLOSE_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
# Server never echoed the close frame within the bound; drop the
|
||||
# connection. websockets force-closes the transport on cancel,
|
||||
# and at shutdown the loop is tearing down anyway.
|
||||
logger.debug(
|
||||
"[%s] WS close handshake exceeded %.1fs — dropping connection",
|
||||
self._adapter.name, WS_CLOSE_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+244
-4334
File diff suppressed because it is too large
Load Diff
@@ -635,6 +635,22 @@ def build_session_key(
|
||||
if source.thread_id:
|
||||
return f"agent:main:{platform}:dm:{dm_chat_id}:{source.thread_id}"
|
||||
return f"agent:main:{platform}:dm:{dm_chat_id}"
|
||||
# No chat_id — fall back to the sender's own identifier before the
|
||||
# bare per-platform sink. Without this, every DM from every user that
|
||||
# arrives without a chat_id (non-standard adapters / synthetic sources)
|
||||
# collapses into one shared "agent:main:<platform>:dm" session, and a
|
||||
# single cached agent ends up serving multiple people's conversations —
|
||||
# cross-user history bleed. participant_id keeps DMs isolated per user.
|
||||
dm_participant_id = source.user_id_alt or source.user_id
|
||||
if dm_participant_id and source.platform == Platform.WHATSAPP:
|
||||
dm_participant_id = (
|
||||
canonical_whatsapp_identifier(str(dm_participant_id))
|
||||
or dm_participant_id
|
||||
)
|
||||
if dm_participant_id:
|
||||
if source.thread_id:
|
||||
return f"agent:main:{platform}:dm:{dm_participant_id}:{source.thread_id}"
|
||||
return f"agent:main:{platform}:dm:{dm_participant_id}"
|
||||
if source.thread_id:
|
||||
return f"agent:main:{platform}:dm:{source.thread_id}"
|
||||
return f"agent:main:{platform}:dm"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ __all__ = [
|
||||
"IS_WINDOWS",
|
||||
"resolve_node_command",
|
||||
"windows_detach_flags",
|
||||
"windows_detach_flags_without_breakaway",
|
||||
"windows_hide_flags",
|
||||
"windows_detach_popen_kwargs",
|
||||
]
|
||||
@@ -152,6 +153,36 @@ def windows_detach_flags() -> int:
|
||||
)
|
||||
|
||||
|
||||
def windows_detach_flags_without_breakaway() -> int:
|
||||
"""Same as :func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``.
|
||||
|
||||
The docstring on :func:`windows_detach_flags` notes that a process in
|
||||
a job which disallows breakaway (no ``JOB_OBJECT_LIMIT_BREAKAWAY_OK``)
|
||||
will see ``ERROR_ACCESS_DENIED`` from CreateProcess, surfacing as
|
||||
``OSError`` (``PermissionError``) on the ``subprocess.Popen`` call.
|
||||
Callers that want to recover — by retrying without the breakaway
|
||||
bit — can pair the two helpers symbolically rather than coding the
|
||||
``& ~0x01000000`` magic at every site:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
try:
|
||||
subprocess.Popen(argv, creationflags=windows_detach_flags(), …)
|
||||
except OSError:
|
||||
subprocess.Popen(
|
||||
argv,
|
||||
creationflags=windows_detach_flags_without_breakaway(),
|
||||
…,
|
||||
)
|
||||
|
||||
See ``gateway_windows.py::_spawn_detached`` for the canonical
|
||||
implementation of this pattern. Returns 0 on non-Windows.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return 0
|
||||
return _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW
|
||||
|
||||
|
||||
def windows_hide_flags() -> int:
|
||||
"""Return Win32 creationflags that merely hide the child's console
|
||||
window without detaching the child. 0 on non-Windows.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -1139,6 +1139,16 @@ DEFAULT_CONFIG = {
|
||||
# Default False matches historical behavior; set to
|
||||
# True if you'd rather pause than silently lose
|
||||
# context turns when your aux model is flaky.
|
||||
"codex_gpt55_autoraise": True, # When True, gpt-5.5 on the ChatGPT Codex OAuth
|
||||
# route raises its compaction trigger to 85% (vs the
|
||||
# global `threshold` above). Codex hard-caps gpt-5.5
|
||||
# at a 272K window, so the default 50% would compact
|
||||
# at ~136K and waste half the usable context. Set to
|
||||
# False to opt back down to the global threshold
|
||||
# (e.g. 0.50) for Codex gpt-5.5 sessions. Only this
|
||||
# exact route is affected — gpt-5.5 on OpenAI's
|
||||
# direct API, OpenRouter, and Copilot keep the
|
||||
# global threshold regardless.
|
||||
},
|
||||
|
||||
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
|
||||
@@ -2258,6 +2268,12 @@ DEFAULT_CONFIG = {
|
||||
# never fires again. Users can wipe the section to re-see all hints.
|
||||
"onboarding": {
|
||||
"seen": {},
|
||||
# Structured profile-build path offered on the very first gateway
|
||||
# message ever. "ask" (default) -> offer to build a user profile
|
||||
# (opt-in, consent-gated; the agent asks before any lookup and never
|
||||
# reads connected accounts silently). "off" -> plain intro only.
|
||||
# The offer fires at most once (latched under onboarding.seen).
|
||||
"profile_build": "ask",
|
||||
},
|
||||
|
||||
# ``hermes update`` behaviour.
|
||||
@@ -2420,7 +2436,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 27,
|
||||
"_config_version": 28,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -2916,6 +2932,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"APIFY_API_TOKEN": {
|
||||
"description": "Apify API token for Actor execution tools (apify_discover, apify_start, apify_collect)",
|
||||
"prompt": "Apify API token",
|
||||
"url": "https://apify.com/account/integrations",
|
||||
"tools": ["apify_discover", "apify_start", "apify_collect"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"SEARXNG_URL": {
|
||||
"description": "URL of your SearXNG instance for free self-hosted web search",
|
||||
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
|
||||
|
||||
@@ -2040,7 +2040,15 @@ def run_doctor(args):
|
||||
_honcho_cfg_path = resolve_config_path()
|
||||
|
||||
if not _honcho_cfg_path.exists():
|
||||
check_warn("Honcho config not found", "run: hermes memory setup")
|
||||
# Config file missing — but env var fallback may have resolved it.
|
||||
# Only warn if the config didn't actually resolve from env vars.
|
||||
if hcfg.api_key or hcfg.base_url:
|
||||
check_ok(
|
||||
"Honcho configured via environment variables",
|
||||
f"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var",
|
||||
)
|
||||
else:
|
||||
check_warn("Honcho config not found", "run: hermes memory setup")
|
||||
elif not hcfg.enabled:
|
||||
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
|
||||
elif not (hcfg.api_key or hcfg.base_url):
|
||||
|
||||
+81
-21
@@ -641,7 +641,10 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
#
|
||||
# ``windows_detach_popen_kwargs()`` returns the right kwargs for the
|
||||
# host platform and is a no-op on POSIX (just ``start_new_session=True``).
|
||||
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
|
||||
from hermes_cli._subprocess_compat import (
|
||||
windows_detach_flags_without_breakaway,
|
||||
windows_detach_popen_kwargs,
|
||||
)
|
||||
|
||||
watcher = textwrap.dedent(
|
||||
"""
|
||||
@@ -677,35 +680,66 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
_DETACHED_PROCESS = 0x00000008
|
||||
_CREATE_NO_WINDOW = 0x08000000
|
||||
_CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
_popen_kwargs["creationflags"] = (
|
||||
_flags = (
|
||||
_CREATE_NEW_PROCESS_GROUP
|
||||
| _DETACHED_PROCESS
|
||||
| _CREATE_NO_WINDOW
|
||||
| _CREATE_BREAKAWAY_FROM_JOB
|
||||
)
|
||||
try:
|
||||
_popen_kwargs["creationflags"] = _flags
|
||||
subprocess.Popen(cmd, **_popen_kwargs)
|
||||
except OSError:
|
||||
# CREATE_BREAKAWAY_FROM_JOB can be rejected with
|
||||
# ERROR_ACCESS_DENIED when the parent's job object refuses
|
||||
# breakaway. Retry without it — DETACHED_PROCESS et al.
|
||||
# alone are enough in most setups. Mirrors the canonical
|
||||
# fallback in gateway_windows._spawn_detached.
|
||||
_popen_kwargs["creationflags"] = _flags & ~_CREATE_BREAKAWAY_FROM_JOB
|
||||
subprocess.Popen(cmd, **_popen_kwargs)
|
||||
else:
|
||||
_popen_kwargs["start_new_session"] = True
|
||||
subprocess.Popen(cmd, **_popen_kwargs)
|
||||
subprocess.Popen(cmd, **_popen_kwargs)
|
||||
"""
|
||||
).strip()
|
||||
|
||||
watcher_argv = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
watcher,
|
||||
str(old_pid),
|
||||
*_gateway_run_args_for_profile(profile),
|
||||
]
|
||||
|
||||
# Same platform-aware detach for the watcher process itself — so
|
||||
# closing the user's terminal doesn't kill the watcher.
|
||||
try:
|
||||
# Same platform-aware detach for the watcher process itself — so
|
||||
# closing the user's terminal doesn't kill the watcher.
|
||||
subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
watcher,
|
||||
str(old_pid),
|
||||
*_gateway_run_args_for_profile(profile),
|
||||
],
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
# CREATE_BREAKAWAY_FROM_JOB rejected by the parent job object
|
||||
# (Electron, Windows Terminal with restrictive job settings, …).
|
||||
# Retry without it. POSIX never reaches this branch — there
|
||||
# ``start_new_session=True`` cannot raise OSError — so the
|
||||
# fallback is only meaningful on Windows.
|
||||
try:
|
||||
fallback_kwargs: dict = (
|
||||
{"creationflags": windows_detach_flags_without_breakaway()}
|
||||
if sys.platform == "win32"
|
||||
else {"start_new_session": True}
|
||||
)
|
||||
subprocess.Popen(
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**fallback_kwargs,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -2375,7 +2409,7 @@ StartLimitIntervalSec=0
|
||||
Type=simple
|
||||
User={username}
|
||||
Group={group_name}
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="HOME={home_dir}"
|
||||
Environment="USER={username}"
|
||||
@@ -2385,8 +2419,6 @@ Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
@@ -2413,15 +2445,13 @@ StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
@@ -2439,6 +2469,29 @@ def _normalize_service_definition(text: str) -> str:
|
||||
return "\n".join(line.rstrip() for line in text.strip().splitlines())
|
||||
|
||||
|
||||
# Directives that older systemd versions silently ignore/strip. Normalize
|
||||
# them out of stale-check comparisons so a unit that differs only by these
|
||||
# directives is not perpetually flagged as outdated.
|
||||
_SYSTEMD_OPTIONAL_DIRECTIVES = (
|
||||
"RestartMaxDelaySec",
|
||||
"RestartSteps",
|
||||
)
|
||||
|
||||
|
||||
def _strip_optional_systemd_directives(text: str) -> str:
|
||||
"""Remove systemd directives that older hosts silently drop."""
|
||||
lines = text.splitlines()
|
||||
filtered = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
key = stripped.split("=", 1)[0].strip()
|
||||
if key in _SYSTEMD_OPTIONAL_DIRECTIVES:
|
||||
continue
|
||||
filtered.append(line)
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
def _normalize_launchd_plist_for_comparison(text: str) -> str:
|
||||
"""Normalize launchd plist text for staleness checks.
|
||||
|
||||
@@ -2466,9 +2519,16 @@ def systemd_unit_is_current(system: bool = False) -> bool:
|
||||
installed = unit_path.read_text(encoding="utf-8")
|
||||
expected_user = _read_systemd_user_from_unit(unit_path) if system else None
|
||||
expected = generate_systemd_unit(system=system, run_as_user=expected_user)
|
||||
return _normalize_service_definition(installed) == _normalize_service_definition(
|
||||
expected
|
||||
# Normalize out directives that older systemd versions silently drop
|
||||
# (RestartMaxDelaySec, RestartSteps) so a unit that differs only by
|
||||
# those directives is not perpetually flagged as outdated.
|
||||
norm_installed = _normalize_service_definition(
|
||||
_strip_optional_systemd_directives(installed)
|
||||
)
|
||||
norm_expected = _normalize_service_definition(
|
||||
_strip_optional_systemd_directives(expected)
|
||||
)
|
||||
return norm_installed == norm_expected
|
||||
|
||||
|
||||
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user