Compare commits
281
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6212b5e4af | ||
|
|
569b912d7d | ||
|
|
2fc0e3d1aa | ||
|
|
05d1ca549b | ||
|
|
07443ea21a | ||
|
|
01b7609252 | ||
|
|
306a3774b6 | ||
|
|
0a940972f4 | ||
|
|
b34e565957 | ||
|
|
97375e0f06 | ||
|
|
a7784f11fb | ||
|
|
d6c14a952f | ||
|
|
3804df5b36 | ||
|
|
05473428cb | ||
|
|
be100d4dae | ||
|
|
dd923619c7 | ||
|
|
3f0b0e20e8 | ||
|
|
f5c2ea49a4 | ||
|
|
91d8e4117c | ||
|
|
9baa7d4673 | ||
|
|
c91e651009 | ||
|
|
6fc24651f7 | ||
|
|
abd7458e77 | ||
|
|
b50b83fbd8 | ||
|
|
9eb89b8a12 | ||
|
|
673a61edc8 | ||
|
|
102d2d5676 | ||
|
|
4e10a38c9e | ||
|
|
6c95740d9e | ||
|
|
ddd6ad43f0 | ||
|
|
d2c81eb681 | ||
|
|
5cbbade24c | ||
|
|
92057474f3 | ||
|
|
f4af35f90d | ||
|
|
47d853fdf2 | ||
|
|
8ff3b67e6d | ||
|
|
51580e192b | ||
|
|
cab457d722 | ||
|
|
0a07609173 | ||
|
|
e12626b34f | ||
|
|
94456a1288 | ||
|
|
f0a8e45cdc | ||
|
|
3ad5876feb | ||
|
|
e357b69a61 | ||
|
|
dbd8704673 | ||
|
|
e9d564c09c | ||
|
|
7df595c58b | ||
|
|
398cf40c08 | ||
|
|
c7489735a9 | ||
|
|
a28201a5e6 | ||
|
|
2dd27c7fcb | ||
|
|
f5f79ff1b4 | ||
|
|
080daa3f42 | ||
|
|
5d410355ac | ||
|
|
0bb3a82c53 | ||
|
|
5646dbdd5b | ||
|
|
3dca75b45c | ||
|
|
ef010f874a | ||
|
|
6997dc81cd | ||
|
|
601c1f16cb | ||
|
|
bb5fc723b6 | ||
|
|
9884b4faad | ||
|
|
df5700ebe3 | ||
|
|
f7198a2055 | ||
|
|
bccc827dbf | ||
|
|
284a3cd47e | ||
|
|
1d98f8dd95 | ||
|
|
d6590f8b17 | ||
|
|
1f216de3a8 | ||
|
|
fc8f8ad33f | ||
|
|
38cfae9b54 | ||
|
|
4554fe128a | ||
|
|
f1328a6bfd | ||
|
|
9a3b676fed | ||
|
|
bdb1c87247 | ||
|
|
1813d3046c | ||
|
|
7e84d2b5a4 | ||
|
|
271a9d8ec6 | ||
|
|
7bb409b2d0 | ||
|
|
e81d18dfb4 | ||
|
|
d9cdb81923 | ||
|
|
1f41bdbecd | ||
|
|
33dfb7e4ec | ||
|
|
f88cac71bc | ||
|
|
0d01831919 | ||
|
|
899e420ab9 | ||
|
|
35d3fc3b09 | ||
|
|
5f3d57400b | ||
|
|
c1e36f4329 | ||
|
|
0031c5c371 | ||
|
|
20502b407c | ||
|
|
77d5b2d573 | ||
|
|
46e87b14fd | ||
|
|
96a0708448 | ||
|
|
f9e35e6e94 | ||
|
|
72ac0d6af0 | ||
|
|
b013ed03e5 | ||
|
|
b4c2c4f922 | ||
|
|
4e6e5181c6 | ||
|
|
b45a217e0a | ||
|
|
34d07732dd | ||
|
|
c084085a3e | ||
|
|
2d0f2185cf | ||
|
|
444b5e96fa | ||
|
|
adf62065a7 | ||
|
|
c5aaf76468 | ||
|
|
b8295cf6f7 | ||
|
|
202be02ac9 | ||
|
|
8ef006933e | ||
|
|
17cfa0f0a5 | ||
|
|
ca559a7852 | ||
|
|
cd53718761 | ||
|
|
e16743b0d5 | ||
|
|
320e886f3d | ||
|
|
55d826ccef | ||
|
|
1b5ceec2a0 | ||
|
|
2a0dd95ccf | ||
|
|
7452467f54 | ||
|
|
ca907480ae | ||
|
|
71e91f89b5 | ||
|
|
d426b9ddfe | ||
|
|
aa56243a89 | ||
|
|
fd461b58ca | ||
|
|
ffa525754d | ||
|
|
78e844d446 | ||
|
|
52cafa6f8e | ||
|
|
3cd8feb63c | ||
|
|
452861fdc1 | ||
|
|
c3656e9f0c | ||
|
|
0684506072 | ||
|
|
370ebf2d35 | ||
|
|
f9c6f92c4b | ||
|
|
9aba95b053 | ||
|
|
03fbf6edbb | ||
|
|
3c2886f599 | ||
|
|
8a7d32d4e4 | ||
|
|
89bd0fba90 | ||
|
|
8582f35d96 | ||
|
|
0d3ad193d6 | ||
|
|
226e8de827 | ||
|
|
b8eb89f5c9 | ||
|
|
c7e09f2571 | ||
|
|
861d69c7bb | ||
|
|
b663d50a6a | ||
|
|
19641fab72 | ||
|
|
4a58e22e99 | ||
|
|
7e201fa1b6 | ||
|
|
2ccfdb2db4 | ||
|
|
af4006000f | ||
|
|
62c8574d86 | ||
|
|
127f6e1514 | ||
|
|
7ef9345a55 | ||
|
|
57f1814832 | ||
|
|
62ea800586 | ||
|
|
2d71e2f1e4 | ||
|
|
658c011266 | ||
|
|
ff52dce1fa | ||
|
|
b708d10db0 | ||
|
|
8341d775a9 | ||
|
|
32bdc67e10 | ||
|
|
962189d9ea | ||
|
|
50aebcbcff | ||
|
|
69fd846ef8 | ||
|
|
0b422559f3 | ||
|
|
a22a1079a3 | ||
|
|
475922f2ce | ||
|
|
a27d51ef46 | ||
|
|
35ebf6ba67 | ||
|
|
ccb045ba7d | ||
|
|
c675e7c793 | ||
|
|
29b8cacfab | ||
|
|
e117478eb3 | ||
|
|
f0b7cf3836 | ||
|
|
7beca22bc0 | ||
|
|
2a25d53ee5 | ||
|
|
39230d1738 | ||
|
|
9f7a3cb1f6 | ||
|
|
a1a4c8ce1f | ||
|
|
10dc1571bc | ||
|
|
2fc3f9c1ff | ||
|
|
fe002eb124 | ||
|
|
ed8ce1f96c | ||
|
|
5d691374c3 | ||
|
|
7577834206 | ||
|
|
7a44a8fdec | ||
|
|
47d56b802a | ||
|
|
79061f447c | ||
|
|
a6857faf48 | ||
|
|
b034fa7026 | ||
|
|
c3aa81be2f | ||
|
|
727025a2f3 | ||
|
|
c5794c505e | ||
|
|
955c5b73c5 | ||
|
|
74c69dc8ed | ||
|
|
c008f41bb9 | ||
|
|
ef61436967 | ||
|
|
999d63b517 | ||
|
|
56bec611e5 | ||
|
|
2762555038 | ||
|
|
6800ec9d66 | ||
|
|
7c98c65163 | ||
|
|
0fac4cd8e9 | ||
|
|
6016997a72 | ||
|
|
92025df393 | ||
|
|
66c097ab78 | ||
|
|
f382ff84f7 | ||
|
|
3c408684ea | ||
|
|
1c557bb98a | ||
|
|
47c47d6a03 | ||
|
|
d6c76cfbfa | ||
|
|
bffc098d09 | ||
|
|
1b8f1504b3 | ||
|
|
ec2cb3ab47 | ||
|
|
4527943e91 | ||
|
|
5265b3002c | ||
|
|
5d41ad710c | ||
|
|
7b3f3047ab | ||
|
|
f0d22e4b6c | ||
|
|
c707165212 | ||
|
|
2d2bed5891 | ||
|
|
297dbf958f | ||
|
|
c44de99854 | ||
|
|
6ce160a5a0 | ||
|
|
af7dceaf77 | ||
|
|
5ce827cac9 | ||
|
|
55624e10b9 | ||
|
|
b104657d5e | ||
|
|
c4622a1d5b | ||
|
|
caed552de1 | ||
|
|
da52ffea14 | ||
|
|
7fe1cb384e | ||
|
|
f6d1fd511c | ||
|
|
6f7ee72be5 | ||
|
|
2627933f33 | ||
|
|
8f29c9f4e3 | ||
|
|
915f1bf1bc | ||
|
|
ffc10cc659 | ||
|
|
104ffeae23 | ||
|
|
021ee34546 | ||
|
|
0512f06a6a | ||
|
|
1215fbbd76 | ||
|
|
77763f00fb | ||
|
|
7fdae5d22a | ||
|
|
7f7a40381e | ||
|
|
3615545bca | ||
|
|
af250d8494 | ||
|
|
d2b2be0f01 | ||
|
|
d0e9a42cec | ||
|
|
67f4e1b4a9 | ||
|
|
94a7705bdd | ||
|
|
bd740f203b | ||
|
|
d921005e25 | ||
|
|
d48bf743f2 | ||
|
|
f96b2e6ef7 | ||
|
|
ac705b52c9 | ||
|
|
b51d365ef0 | ||
|
|
f813c7ddad | ||
|
|
3510b18814 | ||
|
|
dfeedf613d | ||
|
|
b03c94dbed | ||
|
|
2bd721cebc | ||
|
|
98b4562947 | ||
|
|
deae8e3b4d | ||
|
|
e4ea0a0ed7 | ||
|
|
a10081f83b | ||
|
|
402969670d | ||
|
|
8a5f8379ed | ||
|
|
902379ea3e | ||
|
|
80b58ec71d | ||
|
|
0709714a6c | ||
|
|
8030b01a2a | ||
|
|
e6c42b5d80 | ||
|
|
5d524d0427 | ||
|
|
ba7e5b052a | ||
|
|
f2fcf89c1f | ||
|
|
8c288760d0 | ||
|
|
acb3bde097 | ||
|
|
c5e841ab0e | ||
|
|
f846a350c7 | ||
|
|
2f74e29e3b | ||
|
|
0c8cf21882 |
@@ -115,6 +115,10 @@
|
||||
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
|
||||
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
|
||||
|
||||
# DeepInfra — 100+ top open models, pay-per-use.
|
||||
# Get your key at: https://deepinfra.com/dash/api_keys
|
||||
# DEEPINFRA_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Qwen OAuth)
|
||||
# =============================================================================
|
||||
@@ -132,6 +136,15 @@
|
||||
# Optional base URL override:
|
||||
# XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Upstage Solar)
|
||||
# =============================================================================
|
||||
# Upstage provides access to Upstage Solar models.
|
||||
# Get your key at: https://console.upstage.ai/api-keys
|
||||
# UPSTAGE_API_KEY=your_key_here
|
||||
# Optional base URL override:
|
||||
# UPSTAGE_BASE_URL=https://api.upstage.ai/v1
|
||||
|
||||
# =============================================================================
|
||||
# TOOL API KEYS
|
||||
# =============================================================================
|
||||
|
||||
@@ -10,7 +10,7 @@ outputs:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
description: Run the TypeScript testing matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
|
||||
@@ -70,11 +70,11 @@ jobs:
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
js-tests:
|
||||
name: JS & TS checks
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
uses: ./.github/workflows/js-tests.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
needs:
|
||||
- tests
|
||||
- lint
|
||||
- typecheck
|
||||
- js-tests
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
@@ -154,14 +154,18 @@ jobs:
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
echo "$RESULTS" | python3 -c "
|
||||
echo "$NEEDS" | python3 -c "
|
||||
import json, sys
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
needs = json.load(sys.stdin)
|
||||
failed = [name for name, info in needs.items() if info['result'] == 'failure']
|
||||
for name, info in sorted(needs.items()):
|
||||
result = info['result']
|
||||
icon = '✅' if result in ('success', 'skipped') else '❌'
|
||||
print(f'{icon} {name}: {result}')
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# .github/workflows/js-tests.yml
|
||||
name: JS Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
packages: ${{ steps.set-matrix.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- id: set-matrix
|
||||
run: |
|
||||
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
|
||||
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
|
||||
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
|
||||
exit 1
|
||||
fi
|
||||
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check:
|
||||
name: Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: workspaces
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
# --ignore-scripts: TS & tests don't need native deps
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} check
|
||||
@@ -1,51 +0,0 @@
|
||||
# .github/workflows/typecheck.yml
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Check TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
|
||||
# native builds. Skipping install scripts drops node-pty's node-gyp
|
||||
# header fetch — the transient flake that killed this job pre-`tsc` — and
|
||||
# is faster. retry covers the remaining registry blips.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
# which does NOT exercise Vite/Rolldown module resolution — so an
|
||||
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
|
||||
# longer exports "./react-shim") slips past typecheck and only explodes when
|
||||
# users build apps/desktop from source on install/update. Run the real
|
||||
# `vite build` here so that class of break fails in CI instead.
|
||||
desktop-build:
|
||||
name: Build desktop app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Keep install scripts here: the production build may need node-pty's
|
||||
# native binary. retry handles the transient install-time fetch flakes.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
+1
-1
@@ -69,7 +69,7 @@ hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
|
||||
@@ -1354,3 +1354,58 @@ not the specific names.
|
||||
|
||||
Reviewers should reject new change-detector tests; authors should convert
|
||||
them into invariants before re-requesting review.
|
||||
|
||||
### Never read source code in tests
|
||||
|
||||
A test that reads a source file's text is testing *the shape of the
|
||||
source code*, not its behavior. This is a hard antipattern, banned outright.
|
||||
Any test that reads a .py, .ts, .tsx, etc., file is suspect.
|
||||
|
||||
**Why it's actively harmful, not just weak:**
|
||||
|
||||
- It passes when the implementation is subtly broken (the regex matches a
|
||||
call site that exists but is wired wrong) and fails when a correct
|
||||
refactor changes formatting, variable names, or control flow with
|
||||
identical runtime behavior. Both directions of failure are wrong.
|
||||
- It can't be run against a built/bundled/minified artifact, so it silently
|
||||
stops testing anything the moment code moves, gets renamed, or a
|
||||
dependency reformats it.
|
||||
- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
|
||||
fail during pure structural cleanup with no behavior change, and either
|
||||
hand-wave the failure (dangerous) or waste time updating regexes that add
|
||||
nothing (waste).
|
||||
- It gives false confidence. a green suite full of source-regex tests
|
||||
looks like coverage but has never once executed the code path it claims
|
||||
to guard.
|
||||
|
||||
**Do not write:**
|
||||
|
||||
```ts
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
test('backend spawn hides the Windows console', () => {
|
||||
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
|
||||
})
|
||||
```
|
||||
|
||||
**Do write — extract the logic into a small pure/DI-testable function and
|
||||
call it for real:**
|
||||
|
||||
```ts
|
||||
// backend-spawn.ts
|
||||
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
|
||||
if (!isWindows || 'windowsHide' in options) return options
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
// backend-spawn.test.ts
|
||||
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
|
||||
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
|
||||
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
|
||||
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
|
||||
})
|
||||
```
|
||||
|
||||
If the logic lives inline in a god-file (`main.ts`, `cli.py`,
|
||||
`gateway/run.py`) and extracting it feels disruptive: that's the actual
|
||||
signal to do the extraction, not to regex around it.
|
||||
|
||||
@@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
|
||||
def _build_permission_options(
|
||||
*, allow_permanent: bool, smart_denied: bool = False,
|
||||
) -> list[PermissionOption]:
|
||||
"""Return ACP options that match Hermes approval semantics."""
|
||||
options = [
|
||||
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
|
||||
PermissionOption(
|
||||
options = [PermissionOption(
|
||||
option_id="allow_once", kind="allow_once", name="Allow once",
|
||||
)]
|
||||
if not smart_denied:
|
||||
options.append(PermissionOption(
|
||||
option_id="allow_session",
|
||||
# ACP has no session-scoped kind, so use the closest persistent
|
||||
# hint while keeping Hermes semantics in the option id.
|
||||
kind="allow_always",
|
||||
name="Allow for session",
|
||||
),
|
||||
]
|
||||
if allow_permanent:
|
||||
))
|
||||
if allow_permanent and not smart_denied:
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="allow_always",
|
||||
@@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption
|
||||
),
|
||||
)
|
||||
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
|
||||
if _permission_option_supports_kind("reject_always"):
|
||||
if not smart_denied and _permission_option_supports_kind("reject_always"):
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="deny_always",
|
||||
@@ -129,11 +132,15 @@ def make_approval_callback(
|
||||
description: str,
|
||||
*,
|
||||
allow_permanent: bool = True,
|
||||
smart_denied: bool = False,
|
||||
**_: object,
|
||||
) -> str:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
options = _build_permission_options(allow_permanent=allow_permanent)
|
||||
options = _build_permission_options(
|
||||
allow_permanent=allow_permanent,
|
||||
smart_denied=smart_denied,
|
||||
)
|
||||
|
||||
tool_call = _build_permission_tool_call(command, description)
|
||||
coro = request_permission_fn(
|
||||
|
||||
+34
-1
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -14,6 +15,8 @@ from acp.schema import (
|
||||
ToolKind,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map hermes tool names -> ACP ToolKind
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1026,7 +1029,37 @@ def build_tool_start(
|
||||
*,
|
||||
edit_diff: Any = None,
|
||||
) -> ToolCallStart:
|
||||
"""Create a ToolCallStart event for the given hermes tool invocation."""
|
||||
"""Create a ToolCallStart event for the given hermes tool invocation.
|
||||
|
||||
A malformed tool argument (e.g. a non-string ``command``/``path`` from a
|
||||
model that ignores the schema) must never abort the ACP tool-call render —
|
||||
``build_tool_start`` runs on the live tool-progress callback and during
|
||||
session history replay. On any failure in the title/content/location
|
||||
builders, fall back to a minimal, valid start event. Mirrors
|
||||
``get_cute_tool_message`` in ``agent/display.py``, wrapped for the same
|
||||
reason on the CLI side.
|
||||
"""
|
||||
try:
|
||||
return _build_tool_start(
|
||||
tool_call_id, tool_name, arguments, edit_diff=edit_diff
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — a tool-call render must never abort the turn
|
||||
logger.debug("ACP tool-start render failed for %r: %s", tool_name, exc)
|
||||
safe_name = tool_name if isinstance(tool_name, str) and tool_name else "tool"
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, safe_name, kind=get_tool_kind(safe_name),
|
||||
content=None, locations=[], raw_input=None,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool_start(
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
*,
|
||||
edit_diff: Any = None,
|
||||
) -> ToolCallStart:
|
||||
"""Build the ToolCallStart event (unguarded; see ``build_tool_start``)."""
|
||||
kind = get_tool_kind(tool_name)
|
||||
title = build_tool_title(tool_name, arguments)
|
||||
locations = extract_locations(arguments)
|
||||
|
||||
+198
-4
@@ -425,15 +425,28 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
def _codex_backend_urls(base_url: str) -> tuple[str, str, str]:
|
||||
"""Resolve the Codex backend endpoints (usage, reset-credits list, consume).
|
||||
|
||||
Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs
|
||||
containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything
|
||||
else uses ``/api/codex/...``.
|
||||
"""
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
normalized = "https://chatgpt.com/backend-api/codex"
|
||||
if normalized.endswith("/codex"):
|
||||
normalized = normalized[: -len("/codex")]
|
||||
if "/backend-api" in normalized:
|
||||
return normalized + "/wham/usage"
|
||||
return normalized + "/api/codex/usage"
|
||||
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
|
||||
return (
|
||||
prefix + "/usage",
|
||||
prefix + "/rate-limit-reset-credits",
|
||||
prefix + "/rate-limit-reset-credits/consume",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
return _codex_backend_urls(base_url)[0]
|
||||
|
||||
|
||||
def _resolve_codex_usage_credentials(
|
||||
@@ -525,6 +538,14 @@ def _fetch_codex_account_usage(
|
||||
)
|
||||
)
|
||||
details: list[str] = []
|
||||
reset_credits = payload.get("rate_limit_reset_credits") or {}
|
||||
banked = reset_credits.get("available_count")
|
||||
if isinstance(banked, (int, float)) and int(banked) > 0:
|
||||
count = int(banked)
|
||||
plural = "s" if count != 1 else ""
|
||||
details.append(
|
||||
f"You have {count} reset{plural} banked - use /usage reset to activate"
|
||||
)
|
||||
credits = payload.get("credits") or {}
|
||||
if credits.get("has_credits"):
|
||||
balance = credits.get("balance")
|
||||
@@ -542,6 +563,179 @@ def _fetch_codex_account_usage(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodexResetRedeemResult:
|
||||
"""Outcome of a `/usage reset` attempt against the Codex backend."""
|
||||
|
||||
status: str # reset | nothing_to_reset | no_credit | already_redeemed |
|
||||
# not_exhausted | no_credits_banked | unavailable
|
||||
message: str
|
||||
available_count: int = 0
|
||||
windows_reset: int = 0
|
||||
|
||||
@property
|
||||
def redeemed(self) -> bool:
|
||||
return self.status == "reset"
|
||||
|
||||
|
||||
# Client-side guard threshold: a rate-limit window only counts as exhausted
|
||||
# when it is fully used. Below this, redeeming a banked reset wastes most of
|
||||
# its value, so we block and point at --force instead.
|
||||
_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0
|
||||
|
||||
|
||||
def redeem_codex_reset_credit(
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> CodexResetRedeemResult:
|
||||
"""Redeem one banked Codex rate-limit reset credit (`/usage reset`).
|
||||
|
||||
Flow (mirrors the Codex CLI's reset-credits picker, codex-rs
|
||||
``backend-client``):
|
||||
|
||||
1. ``GET .../usage`` — read the current windows + banked credit count.
|
||||
2. Guard: zero banked credits → refuse. No window fully used and not
|
||||
``force`` → refuse with a warning (a banked reset restores the WHOLE
|
||||
5h + weekly allowance; burning it early wastes it). The backend has
|
||||
the same protection (``nothing_to_reset`` doesn't consume the
|
||||
credit), but failing fast client-side gives a clearer message.
|
||||
3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID
|
||||
idempotency key (``redeem_request_id``). No ``credit_id`` — the
|
||||
backend picks the next available credit, exactly like the CLI's
|
||||
default "Full reset" option.
|
||||
|
||||
Never raises: every failure mode returns a ``CodexResetRedeemResult``
|
||||
with a user-renderable message.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
try:
|
||||
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
|
||||
except Exception:
|
||||
return CodexResetRedeemResult(
|
||||
status="unavailable",
|
||||
message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.",
|
||||
)
|
||||
usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "codex-cli",
|
||||
}
|
||||
if account_id:
|
||||
headers["ChatGPT-Account-Id"] = account_id
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
usage_resp = client.get(usage_url, headers=headers)
|
||||
usage_resp.raise_for_status()
|
||||
payload = usage_resp.json() or {}
|
||||
|
||||
reset_credits = payload.get("rate_limit_reset_credits") or {}
|
||||
raw_count = reset_credits.get("available_count")
|
||||
available = int(raw_count) if isinstance(raw_count, (int, float)) else 0
|
||||
if available <= 0:
|
||||
return CodexResetRedeemResult(
|
||||
status="no_credits_banked",
|
||||
message="No banked reset credits on this account — nothing to redeem.",
|
||||
)
|
||||
|
||||
rate_limit = payload.get("rate_limit") or {}
|
||||
worst_used: Optional[float] = None
|
||||
for key in ("primary_window", "secondary_window"):
|
||||
used = (rate_limit.get(key) or {}).get("used_percent")
|
||||
if isinstance(used, (int, float)):
|
||||
worst_used = max(worst_used or 0.0, float(used))
|
||||
exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT
|
||||
if not exhausted and not force:
|
||||
usage_note = (
|
||||
f"your busiest window is only {worst_used:.0f}% used"
|
||||
if worst_used is not None
|
||||
else "your current usage could not be confirmed as exhausted"
|
||||
)
|
||||
plural = "s" if available != 1 else ""
|
||||
return CodexResetRedeemResult(
|
||||
status="not_exhausted",
|
||||
message=(
|
||||
f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL "
|
||||
f"5h + weekly limits, so spending it now would waste most of it. "
|
||||
f"You have {available} reset{plural} banked. "
|
||||
f"Use `/usage reset --force` to redeem anyway."
|
||||
),
|
||||
available_count=available,
|
||||
)
|
||||
|
||||
consume_resp = client.post(
|
||||
consume_url,
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json={"redeem_request_id": str(uuid.uuid4())},
|
||||
)
|
||||
consume_resp.raise_for_status()
|
||||
body = consume_resp.json() or {}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
code = exc.response.status_code
|
||||
if code in (401, 403):
|
||||
return CodexResetRedeemResult(
|
||||
status="unavailable",
|
||||
message=(
|
||||
"Codex backend rejected the request (HTTP "
|
||||
f"{code}). Reset credits require ChatGPT-account (OAuth) auth — "
|
||||
"run `hermes auth` and sign in with your ChatGPT account."
|
||||
),
|
||||
)
|
||||
return CodexResetRedeemResult(
|
||||
status="unavailable",
|
||||
message=f"Codex backend error (HTTP {code}) — try again shortly.",
|
||||
)
|
||||
except Exception as exc:
|
||||
return CodexResetRedeemResult(
|
||||
status="unavailable",
|
||||
message=f"Could not reach the Codex backend: {exc}",
|
||||
)
|
||||
|
||||
code = str(body.get("code", "") or "").strip().lower()
|
||||
windows_reset = body.get("windows_reset")
|
||||
windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0
|
||||
remaining = max(0, available - 1)
|
||||
plural = "s" if remaining != 1 else ""
|
||||
if code == "reset":
|
||||
return CodexResetRedeemResult(
|
||||
status="reset",
|
||||
message=(
|
||||
f"✅ Reset redeemed — your usage limits have been reset. "
|
||||
f"{remaining} banked reset{plural} remaining."
|
||||
),
|
||||
available_count=remaining,
|
||||
windows_reset=windows_reset,
|
||||
)
|
||||
if code == "nothing_to_reset":
|
||||
return CodexResetRedeemResult(
|
||||
status="nothing_to_reset",
|
||||
message=(
|
||||
"Backend reports nothing to reset — your limits aren't exhausted. "
|
||||
"The credit was NOT spent."
|
||||
),
|
||||
available_count=available,
|
||||
)
|
||||
if code == "no_credit":
|
||||
return CodexResetRedeemResult(
|
||||
status="no_credit",
|
||||
message="Backend reports no available reset credit on this account.",
|
||||
)
|
||||
if code == "already_redeemed":
|
||||
return CodexResetRedeemResult(
|
||||
status="already_redeemed",
|
||||
message="This redemption was already processed — no additional credit was spent.",
|
||||
available_count=remaining,
|
||||
)
|
||||
return CodexResetRedeemResult(
|
||||
status="unavailable",
|
||||
message=f"Unexpected response from the Codex backend: {body!r}",
|
||||
)
|
||||
|
||||
|
||||
def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
|
||||
token = (resolve_anthropic_token() or "").strip()
|
||||
if not token:
|
||||
|
||||
+45
-15
@@ -187,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str:
|
||||
|
||||
|
||||
def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool:
|
||||
provider_model = str(entry.get("model", "") or "").strip().lower()
|
||||
if not provider_model:
|
||||
agent_model_norm = str(agent_model or "").strip().lower()
|
||||
# Multi-model entries (v12+ `providers.<name>.models` mapping / legacy
|
||||
# `models:` list): the agent's model matching ANY catalog entry counts.
|
||||
# Without this, a provider whose `model`/`default_model` differs from the
|
||||
# session model silently fails to match and per-provider request settings
|
||||
# (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole
|
||||
# session at the wrong tier (July 2026 sweeper incident: flex config
|
||||
# ignored, ~2.3x overbilling).
|
||||
models = entry.get("models")
|
||||
catalog: List[str] = []
|
||||
if isinstance(models, dict):
|
||||
catalog = [str(k).strip().lower() for k in models.keys()]
|
||||
elif isinstance(models, (list, tuple)):
|
||||
catalog = [str(m).strip().lower() for m in models]
|
||||
if catalog and agent_model_norm in catalog:
|
||||
return True
|
||||
return provider_model == str(agent_model or "").strip().lower()
|
||||
provider_model = str(entry.get("model", "") or "").strip().lower()
|
||||
if not provider_model and not catalog:
|
||||
return True
|
||||
return provider_model == agent_model_norm
|
||||
|
||||
|
||||
def _custom_provider_extra_body_for_agent(
|
||||
@@ -418,18 +434,6 @@ def init_agent(
|
||||
agent.base_url = base_url or ""
|
||||
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
||||
agent.provider = provider_name or ""
|
||||
if credential_pool is not None:
|
||||
try:
|
||||
from agent.credential_pool import credential_pool_matches_provider
|
||||
|
||||
if not credential_pool_matches_provider(
|
||||
credential_pool,
|
||||
agent.provider,
|
||||
base_url=agent.base_url,
|
||||
):
|
||||
credential_pool = None
|
||||
except Exception:
|
||||
credential_pool = None
|
||||
agent._credential_pool = credential_pool
|
||||
agent.acp_command = acp_command or command
|
||||
agent.acp_args = list(acp_args or args or [])
|
||||
@@ -466,6 +470,24 @@ def init_agent(
|
||||
else:
|
||||
agent.api_mode = "chat_completions"
|
||||
|
||||
# Credential-pool validation runs AFTER provider auto-detection so
|
||||
# a pool scoped to e.g. "anthropic" is not rejected when the agent
|
||||
# was constructed with provider=None and an anthropic.com URL.
|
||||
# Regression from #63048 which placed this check before the
|
||||
# URL-based auto-detection block above (fixed #63425).
|
||||
if credential_pool is not None:
|
||||
try:
|
||||
from agent.credential_pool import credential_pool_matches_provider
|
||||
|
||||
if not credential_pool_matches_provider(
|
||||
credential_pool,
|
||||
agent.provider,
|
||||
base_url=agent.base_url,
|
||||
):
|
||||
agent._credential_pool = None
|
||||
except Exception:
|
||||
agent._credential_pool = None
|
||||
|
||||
# Eagerly warm the transport cache so import errors surface at init,
|
||||
# not mid-conversation. Also validates the api_mode is registered.
|
||||
try:
|
||||
@@ -1295,6 +1317,14 @@ def init_agent(
|
||||
# SQLite session store (optional -- provided by CLI or gateway)
|
||||
agent._session_db = session_db
|
||||
agent._parent_session_id = parent_session_id
|
||||
# A close flush and the worker's turn-start flush can overlap. The durable
|
||||
# marker is attached to each in-memory message dict, so its test-and-append
|
||||
# sequence must be serialized per agent rather than relying on SQLite alone.
|
||||
agent._session_persist_lock = threading.RLock()
|
||||
# CLI retains its just-accepted user dict until turn setup can reuse it.
|
||||
# This preserves the message-local durable marker if close persistence wins
|
||||
# the race before the agent's normal early turn flush.
|
||||
agent._pending_cli_user_message = None
|
||||
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
|
||||
agent._session_db_created = False # DB row deferred to run_conversation()
|
||||
# Most agents own their session row and should finalize it on close().
|
||||
|
||||
@@ -1305,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool:
|
||||
primary_provider or "?",
|
||||
)
|
||||
|
||||
# ── Restore reasoning_config if it was saved ──
|
||||
# switch_model saves reasoning_config in _primary_runtime. If the
|
||||
# snapshot predates that (older sessions), keep the current value.
|
||||
saved_reasoning = rt.get("reasoning_config")
|
||||
if saved_reasoning is not None:
|
||||
agent.reasoning_config = dict(saved_reasoning)
|
||||
|
||||
# ── Reset fallback chain for the new turn ──
|
||||
agent._fallback_activated = False
|
||||
agent._fallback_index = 0
|
||||
@@ -2065,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
|
||||
# ── Re-resolve reasoning_config from per-model override ──
|
||||
# The new model may have a different reasoning_effort override. Re-read
|
||||
# config so the override takes effect immediately on /model switch —
|
||||
# resolved through the shared chokepoint (per-model > global; YAML
|
||||
# boolean False = disabled).
|
||||
try:
|
||||
from hermes_constants import resolve_reasoning_config
|
||||
from hermes_cli.config import load_config as _sm_load_config
|
||||
|
||||
_reasoning_cfg = _sm_load_config() or {}
|
||||
agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model)
|
||||
logger.info(
|
||||
"switch_model: reasoning_config resolved for %s: %s",
|
||||
agent.model, agent.reasoning_config,
|
||||
)
|
||||
except Exception as _reasoning_err:
|
||||
logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err)
|
||||
|
||||
# ── Invalidate cached system prompt so it rebuilds next turn ──
|
||||
agent._cached_system_prompt = None
|
||||
|
||||
@@ -2087,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
"client_kwargs": dict(agent._client_kwargs),
|
||||
"use_prompt_caching": agent._use_prompt_caching,
|
||||
"use_native_cache_layout": agent._use_native_cache_layout,
|
||||
"reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None,
|
||||
"compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model,
|
||||
"compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url,
|
||||
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
|
||||
|
||||
+258
-20
@@ -474,6 +474,10 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
|
||||
"kilocode": "google/gemini-3-flash-preview",
|
||||
"ollama-cloud": "nemotron-3-nano:30b",
|
||||
"tencent-tokenhub": "hy3-preview",
|
||||
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
|
||||
# (plugins/model-providers/deepinfra: default_aux_model), which
|
||||
# _get_aux_model_for_provider() reads first. Duplicating it here would be
|
||||
# dead data that drifts when the profile's value is bumped.
|
||||
}
|
||||
|
||||
# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
|
||||
@@ -489,6 +493,33 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = {
|
||||
"zai": "glm-5v-turbo",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_provider_vision_default(provider: str) -> Optional[str]:
|
||||
"""Return the provider's preferred default vision model id, or None.
|
||||
|
||||
Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi /
|
||||
zai have dedicated vision-only model names that don't live in any
|
||||
discoverable catalog). Otherwise the provider's :class:`ProviderProfile`
|
||||
gets a chance to supply one via its ``default_vision_model()`` hook —
|
||||
that's where catalog-backed providers (DeepInfra) resolve a live default,
|
||||
keeping the discovery logic inside their plugin instead of a name-check
|
||||
branch here.
|
||||
"""
|
||||
static = _PROVIDER_VISION_MODELS.get(provider)
|
||||
if static:
|
||||
return static
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
profile = get_provider_profile(provider)
|
||||
except Exception:
|
||||
return None
|
||||
if profile is None:
|
||||
return None
|
||||
try:
|
||||
return profile.default_vision_model()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Providers whose endpoint does not accept image input, even though the
|
||||
# provider's broader ecosystem has vision models available elsewhere. When
|
||||
# `auxiliary.vision.provider: auto` sees one of these as the main provider,
|
||||
@@ -1255,6 +1286,7 @@ class _AnthropicCompletionsAdapter:
|
||||
model = kwargs.get("model", self._model)
|
||||
tools = kwargs.get("tools")
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
reasoning_config = kwargs.get("_reasoning_config")
|
||||
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
|
||||
# models (glm-4v-flash etc.) with error code 1210. When the caller
|
||||
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
|
||||
@@ -1275,12 +1307,25 @@ class _AnthropicCompletionsAdapter:
|
||||
elif choice_type in {"auto", "required", "none"}:
|
||||
normalized_tool_choice = choice_type
|
||||
|
||||
# Reasoning priority: explicit per-call reasoning_config (MoA per-slot,
|
||||
# passed as _reasoning_config by _build_call_kwargs) wins over an
|
||||
# extra_body.reasoning dict (auxiliary.<task>.extra_body config).
|
||||
# build_anthropic_kwargs translates the config dict into the native
|
||||
# ``thinking`` field and handles models where thinking is mandatory.
|
||||
_reasoning_cfg = reasoning_config
|
||||
if _reasoning_cfg is None:
|
||||
_eb = kwargs.get("extra_body")
|
||||
if isinstance(_eb, dict):
|
||||
_rc = _eb.get("reasoning")
|
||||
if isinstance(_rc, dict):
|
||||
_reasoning_cfg = _rc
|
||||
|
||||
anthropic_kwargs = build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=None,
|
||||
reasoning_config=_reasoning_cfg,
|
||||
tool_choice=normalized_tool_choice,
|
||||
is_oauth=self._is_oauth,
|
||||
)
|
||||
@@ -3398,6 +3443,7 @@ def _retry_same_provider_sync(
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
reasoning_config: Optional[dict],
|
||||
) -> Any:
|
||||
if task == "vision":
|
||||
_, retry_client, retry_model = resolve_vision_provider_client(
|
||||
@@ -3431,6 +3477,7 @@ def _retry_same_provider_sync(
|
||||
tools=tools,
|
||||
timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
@@ -3455,6 +3502,7 @@ async def _retry_same_provider_async(
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
reasoning_config: Optional[dict],
|
||||
) -> Any:
|
||||
if task == "vision":
|
||||
_, retry_client, retry_model = resolve_vision_provider_client(
|
||||
@@ -3488,6 +3536,7 @@ async def _retry_same_provider_async(
|
||||
tools=tools,
|
||||
timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
@@ -3607,6 +3656,7 @@ def _call_fallback_candidate_sync(
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
reasoning_config: Optional[dict],
|
||||
) -> Optional[Any]:
|
||||
"""Call one fallback candidate with stale-credential recovery.
|
||||
|
||||
@@ -3628,7 +3678,8 @@ def _call_fallback_candidate_sync(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, base_url=fb_base)
|
||||
extra_body=effective_extra_body, reasoning_config=reasoning_config,
|
||||
base_url=fb_base)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
@@ -3644,6 +3695,7 @@ def _call_fallback_candidate_sync(
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
@@ -3676,6 +3728,7 @@ async def _call_fallback_candidate_async(
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
reasoning_config: Optional[dict],
|
||||
) -> Optional[Any]:
|
||||
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
@@ -3683,7 +3736,8 @@ async def _call_fallback_candidate_async(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, base_url=fb_base)
|
||||
extra_body=effective_extra_body, reasoning_config=reasoning_config,
|
||||
base_url=fb_base)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
@@ -3700,6 +3754,7 @@ async def _call_fallback_candidate_async(
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
@@ -5178,6 +5233,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di
|
||||
_VISION_AUTO_PROVIDER_ORDER = (
|
||||
"openrouter",
|
||||
"nous",
|
||||
"deepinfra",
|
||||
)
|
||||
|
||||
|
||||
@@ -5234,6 +5290,21 @@ def _resolve_strict_vision_backend(
|
||||
return resolve_provider_client("openai-codex", model, is_vision=True)
|
||||
if provider == "anthropic":
|
||||
return _try_anthropic()
|
||||
if provider == "deepinfra":
|
||||
# DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick,
|
||||
# Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint
|
||||
# as its chat models. The default is discovered live via the profile's
|
||||
# default_vision_model() hook (key-gated, chat-surface + vision tag) so
|
||||
# we don't pin a hardcoded id that may rot when DeepInfra retires a
|
||||
# model, and this module stays provider-agnostic.
|
||||
vision_model = model or _resolve_provider_vision_default("deepinfra")
|
||||
if not vision_model:
|
||||
logger.debug(
|
||||
"Vision auto-detect: deepinfra catalog unreachable or "
|
||||
"returned no vision-tagged models — skipping"
|
||||
)
|
||||
return None, None
|
||||
return resolve_provider_client("deepinfra", vision_model, is_vision=True)
|
||||
if provider == "custom":
|
||||
return _try_custom_endpoint()
|
||||
return None, None
|
||||
@@ -5319,16 +5390,29 @@ def resolve_vision_provider_client(
|
||||
# _PROVIDER_VISION_MODELS provides per-provider vision model
|
||||
# overrides when the provider has a dedicated multimodal model
|
||||
# that differs from the chat model (e.g. xiaomi → mimo-v2-omni,
|
||||
# zai → glm-5v-turbo). Nous is the exception: it has a dedicated
|
||||
# strict vision backend with tier-aware defaults, so it must not
|
||||
# fall through to the user's text chat model here.
|
||||
# 2. OpenRouter (vision-capable aggregator fallback)
|
||||
# zai → glm-5v-turbo). DeepInfra is similar but resolves its
|
||||
# default vision model live from the catalog (see
|
||||
# :func:`_resolve_provider_vision_default`). Nous is the
|
||||
# exception: it has a dedicated strict vision backend with
|
||||
# tier-aware defaults, so it must not fall through to the
|
||||
# user's text chat model here.
|
||||
# 2. OpenRouter (vision-capable aggregator fallback)
|
||||
# 3. Nous Portal (vision-capable aggregator fallback)
|
||||
# 4. Stop
|
||||
# 4. DeepInfra (OpenAI-compatible; vision model discovered
|
||||
# live from the catalog — tried when
|
||||
# DEEPINFRA_API_KEY is set)
|
||||
# 5. Stop
|
||||
main_provider = _read_main_provider()
|
||||
main_model = _read_main_model()
|
||||
if main_provider and main_provider not in {"auto", ""}:
|
||||
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
|
||||
# A provider-specific vision default wins over the user's chat model:
|
||||
# static overrides (xiaomi/zai) and catalog-backed discovery (the
|
||||
# DeepInfra profile hook) both yield a *known* vision-capable model,
|
||||
# whereas the pinned chat model is usually NOT multimodal (e.g. the
|
||||
# DeepSeek-V4-Flash default) and _main_model_supports_vision can't be
|
||||
# trusted to catch that. Only fall back to the chat model when no
|
||||
# provider default is available (catalog unreachable).
|
||||
vision_model = _resolve_provider_vision_default(main_provider) or main_model
|
||||
if main_provider == "nous":
|
||||
sync_client, default_model = _resolve_strict_vision_backend(
|
||||
main_provider, vision_model
|
||||
@@ -6081,12 +6165,49 @@ def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
|
||||
|
||||
|
||||
def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
|
||||
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid.
|
||||
|
||||
Also folds in ``auxiliary.<task>.reasoning_effort`` as an
|
||||
``extra_body.reasoning`` config dict ({"enabled": ..., "effort": ...})
|
||||
when set. An explicit ``extra_body.reasoning`` in config wins over the
|
||||
``reasoning_effort`` shorthand (it is the more specific wire control).
|
||||
Downstream, each wire already translates ``extra_body.reasoning``:
|
||||
chat.completions passes it through, the Codex Responses adapter maps it
|
||||
to top-level ``reasoning``/``include``, and the Anthropic auxiliary
|
||||
client maps it to ``build_anthropic_kwargs(reasoning_config=...)``.
|
||||
|
||||
MoA tasks are excluded by design: reasoning depth for MoA is a per-slot
|
||||
setting in the MoA preset (``moa.presets.<name>.reference_models[].
|
||||
reasoning_effort`` / ``aggregator.reasoning_effort``), not an
|
||||
auxiliary-task knob — an ensemble-wide value would override the
|
||||
per-slot ones.
|
||||
"""
|
||||
task_config = _get_auxiliary_task_config(task)
|
||||
raw = task_config.get("extra_body")
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
return {}
|
||||
result = dict(raw) if isinstance(raw, dict) else {}
|
||||
if "reasoning" not in result:
|
||||
effort = task_config.get("reasoning_effort")
|
||||
if effort is not None and effort != "":
|
||||
if task in ("moa_reference", "moa_aggregator"):
|
||||
logger.warning(
|
||||
"auxiliary.%s.reasoning_effort is not supported — MoA "
|
||||
"reasoning depth is per-slot: set reasoning_effort on the "
|
||||
"preset's reference_models entries / aggregator instead "
|
||||
"(moa.presets.<name>...). Ignoring.",
|
||||
task,
|
||||
)
|
||||
return result
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
parsed = parse_reasoning_effort(effort)
|
||||
if parsed is not None:
|
||||
result["reasoning"] = parsed
|
||||
else:
|
||||
logger.warning(
|
||||
"auxiliary.%s.reasoning_effort %r is not a valid level "
|
||||
"(none, minimal, low, medium, high, xhigh, max, ultra) — ignoring",
|
||||
task, effort,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -6194,6 +6315,32 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
return converted
|
||||
|
||||
|
||||
_PROFILE_REASONING_KEYS = {
|
||||
"reasoning",
|
||||
"reasoning_effort",
|
||||
"thinking",
|
||||
"thinking_config",
|
||||
"thinkingconfig",
|
||||
"thinking_budget",
|
||||
"thinkingbudget",
|
||||
"enable_thinking",
|
||||
"think",
|
||||
"verbosity",
|
||||
}
|
||||
|
||||
|
||||
def _contains_profile_reasoning_fields(value: Any) -> bool:
|
||||
"""Return whether a profile payload contains a reasoning wire control."""
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
for key, nested in value.items():
|
||||
normalized = str(key).strip().lower()
|
||||
if normalized in _PROFILE_REASONING_KEYS:
|
||||
return True
|
||||
if _contains_profile_reasoning_fields(nested):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_call_kwargs(
|
||||
provider: str,
|
||||
@@ -6204,6 +6351,7 @@ def _build_call_kwargs(
|
||||
tools: Optional[list] = None,
|
||||
timeout: float = 30.0,
|
||||
extra_body: Optional[dict] = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
|
||||
@@ -6287,13 +6435,89 @@ def _build_call_kwargs(
|
||||
_deduped.append(_t)
|
||||
kwargs["tools"] = _deduped
|
||||
|
||||
# Provider-specific extra_body
|
||||
# Build provider-aware reasoning kwargs through the same profile hooks used
|
||||
# by the standard chat-completions transport. Some providers require
|
||||
# top-level controls (Kimi/custom ``reasoning_effort``), others use nested
|
||||
# body fields (Gemini ``thinking_config``), and OpenRouter/Nous use
|
||||
# ``extra_body.reasoning``. Profiles are the source of truth for those wire
|
||||
# shapes. Providers without a reasoning-aware profile retain the generic
|
||||
# ``extra_body.reasoning`` fallback used by Codex-compatible adapters.
|
||||
effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
)
|
||||
profile_body: Dict[str, Any] = {}
|
||||
profile_reasoning_extra: Dict[str, Any] = {}
|
||||
profile_top_level: Dict[str, Any] = {}
|
||||
profile_handles_reasoning = False
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
profile = get_provider_profile(str(provider or "").strip().lower())
|
||||
if profile is not None:
|
||||
profile_body = profile.build_extra_body(
|
||||
model=model,
|
||||
base_url=effective_base,
|
||||
reasoning_config=reasoning_config,
|
||||
) or {}
|
||||
profile_reasoning_extra, profile_top_level = (
|
||||
profile.build_api_kwargs_extras(
|
||||
reasoning_config=reasoning_config,
|
||||
supports_reasoning=reasoning_config is not None,
|
||||
model=model,
|
||||
base_url=effective_base,
|
||||
)
|
||||
)
|
||||
profile_reasoning_extra = profile_reasoning_extra or {}
|
||||
profile_top_level = profile_top_level or {}
|
||||
profile_handles_reasoning = (
|
||||
type(profile).build_api_kwargs_extras
|
||||
is not ProviderProfile.build_api_kwargs_extras
|
||||
or _contains_profile_reasoning_fields(profile_body)
|
||||
or _contains_profile_reasoning_fields(profile_reasoning_extra)
|
||||
or _contains_profile_reasoning_fields(profile_top_level)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"_build_call_kwargs: provider profile projection failed for %s: %s",
|
||||
provider,
|
||||
exc,
|
||||
)
|
||||
|
||||
kwargs.update(profile_top_level)
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous":
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
merged_extra.update(profile_body)
|
||||
merged_extra.update(profile_reasoning_extra)
|
||||
if (
|
||||
reasoning_config
|
||||
and isinstance(reasoning_config, dict)
|
||||
and not profile_handles_reasoning
|
||||
):
|
||||
if reasoning_config.get("enabled") is False:
|
||||
merged_extra["reasoning"] = {"enabled": False}
|
||||
else:
|
||||
effort = reasoning_config.get("effort") or "medium"
|
||||
merged_extra["reasoning"] = {"enabled": True, "effort": effort}
|
||||
if provider == "nous" and "tags" not in merged_extra:
|
||||
merged_extra["tags"] = _nous_portal_tags()
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
# Native Anthropic Messages adapters do not consume ``extra_body``. Carry
|
||||
# the normalized Hermes reasoning config through a private kwarg so the
|
||||
# adapter can pass it into build_anthropic_kwargs(), where provider-aware
|
||||
# thinking/output_config projection lives. Do not expose this private kwarg
|
||||
# to ordinary OpenAI-compatible SDK clients, which would reject it.
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
provider_norm = str(provider or "").strip().lower()
|
||||
effective_base = base_url or ""
|
||||
if (
|
||||
provider_norm == "anthropic"
|
||||
or _endpoint_speaks_anthropic_messages(effective_base)
|
||||
or _is_anthropic_compat_endpoint(provider_norm, effective_base)
|
||||
):
|
||||
kwargs["_reasoning_config"] = dict(reasoning_config)
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -6403,6 +6627,7 @@ def call_llm(
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
api_mode: str = None,
|
||||
stream: bool = False,
|
||||
stream_options: dict = None,
|
||||
@@ -6426,6 +6651,8 @@ def call_llm(
|
||||
tools: Tool definitions (for function calling).
|
||||
timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config).
|
||||
extra_body: Additional request body fields.
|
||||
reasoning_config: Optional Hermes reasoning config for direct model calls
|
||||
such as MoA reference/aggregator slots.
|
||||
stream: When True, return the raw SDK streaming iterator instead of a
|
||||
validated complete response. The caller is responsible for consuming
|
||||
chunks (and for any fallback). Used by the MoA aggregator so its
|
||||
@@ -6530,6 +6757,7 @@ def call_llm(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=_base_info or resolved_base_url)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
@@ -6782,6 +7010,7 @@ def call_llm(
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
)
|
||||
|
||||
# ── Same-provider credential-pool recovery ─────────────────────
|
||||
@@ -6824,6 +7053,7 @@ def call_llm(
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
)
|
||||
except Exception as retry2_err:
|
||||
# The rotated key also hit a quota/auth wall. Mark it
|
||||
@@ -6945,7 +7175,8 @@ def call_llm(
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# The candidate had a stale/unrefreshable credential and was
|
||||
@@ -6959,7 +7190,8 @@ def call_llm(
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# All fallback layers exhausted — emit a single user-visible
|
||||
@@ -7054,6 +7286,7 @@ async def async_call_llm(
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
) -> Any:
|
||||
"""Centralized asynchronous LLM call.
|
||||
|
||||
@@ -7133,6 +7366,7 @@ async def async_call_llm(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=_client_base or resolved_base_url)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
@@ -7329,6 +7563,7 @@ async def async_call_llm(
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
)
|
||||
|
||||
# ── Same-provider credential-pool recovery (mirrors sync) ─────
|
||||
@@ -7366,6 +7601,7 @@ async def async_call_llm(
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
)
|
||||
except Exception as retry2_err:
|
||||
if (_is_payment_error(retry2_err) or _is_auth_error(retry2_err)
|
||||
@@ -7454,7 +7690,8 @@ async def async_call_llm(
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# Stale/unrefreshable candidate credential — quarantined; walk
|
||||
@@ -7470,7 +7707,8 @@ async def async_call_llm(
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
effective_extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# All fallback layers exhausted — warn before re-raising. (#26882)
|
||||
|
||||
@@ -696,6 +696,19 @@ def _run_review_in_thread(
|
||||
if isinstance(_rt.get("command"), str) and _rt["command"]:
|
||||
_fork_kwargs["acp_command"] = _rt["command"]
|
||||
_fork_kwargs["acp_args"] = _rt.get("args") or []
|
||||
# Match parent's reasoning config so the fork's ``thinking`` /
|
||||
# ``output_config`` are byte-identical in the request body —
|
||||
# Anthropic's cache key is namespaced by ``thinking`` presence.
|
||||
# Same-model path only: when routed to a different aux model the
|
||||
# cache is cold regardless (parity buys nothing) and the parent's
|
||||
# effort vocabulary may not be valid for the routed model/provider
|
||||
# (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded
|
||||
# unclamped; codex_responses passes ``max``/``ultra`` through
|
||||
# unmapped except on gpt-5.6/xAI). Let the routed fork use
|
||||
# provider defaults — matching the ``not _routed`` gate on
|
||||
# _cached_system_prompt below.
|
||||
if not _routed:
|
||||
_fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None)
|
||||
review_agent = AIAgent(
|
||||
model=_rt.get("model") or agent.model,
|
||||
max_iterations=16,
|
||||
|
||||
@@ -28,6 +28,7 @@ from typing import Any, Dict, Optional
|
||||
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.errors import EmptyStreamError
|
||||
from agent.gemini_native_adapter import is_native_gemini_base_url
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
from agent.message_sanitization import (
|
||||
@@ -533,25 +534,28 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
and _ttfb_disable_above > 0
|
||||
and _est_tokens_for_codex_watchdog >= _ttfb_disable_above
|
||||
):
|
||||
_ttfb_enabled = False
|
||||
logger.info(
|
||||
"Disabling openai-codex no-byte TTFB watchdog for large request "
|
||||
"(context=~%s tokens >= %.0f). Waiting for backend response instead. "
|
||||
"Set HERMES_CODEX_TTFB_STRICT=1 to force early reconnects.",
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
_ttfb_disable_above,
|
||||
)
|
||||
else:
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0)
|
||||
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
|
||||
_large_request_ttfb_timeout = _codex_idle_timeout_default
|
||||
if _ttfb_timeout < _large_request_ttfb_timeout:
|
||||
logger.info(
|
||||
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
|
||||
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
|
||||
"Scaling openai-codex no-byte TTFB watchdog from %.0fs to %.0fs "
|
||||
"for large request (context=~%s tokens >= %.0f). "
|
||||
"Set HERMES_CODEX_TTFB_STRICT=1 to keep the smaller cutoff.",
|
||||
_ttfb_timeout,
|
||||
_ttfb_cap,
|
||||
_large_request_ttfb_timeout,
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
_ttfb_disable_above,
|
||||
)
|
||||
_ttfb_timeout = _ttfb_cap
|
||||
_ttfb_timeout = _large_request_ttfb_timeout
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0)
|
||||
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
|
||||
logger.info(
|
||||
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
|
||||
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
|
||||
_ttfb_timeout,
|
||||
_ttfb_cap,
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
_ttfb_timeout = _ttfb_cap
|
||||
|
||||
_codex_idle_enabled = _codex_watchdog_enabled
|
||||
_codex_idle_timeout = _env_float(
|
||||
@@ -577,12 +581,23 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
t.join(timeout=0.3)
|
||||
_poll_count += 1
|
||||
|
||||
# Touch activity every ~30s so the gateway's inactivity
|
||||
# monitor knows we're alive while waiting for the response.
|
||||
# Every ~30s: touch activity for the gateway inactivity monitor AND
|
||||
# rewrite the live spinner/status line so CLI/TUI/Desktop users see
|
||||
# what the agent is waiting on instead of an unexplained generic
|
||||
# spinner (the "infinite thinking" complaint — the wait itself is
|
||||
# usually a slow/overloaded provider, but the UI never said so).
|
||||
if _poll_count % 100 == 0: # 100 × 0.3s = 30s
|
||||
_elapsed = time.time() - _call_start
|
||||
agent._touch_activity(
|
||||
f"waiting for non-streaming response ({int(_elapsed)}s elapsed)"
|
||||
_deadline = _stale_timeout
|
||||
if (
|
||||
_ttfb_enabled
|
||||
and getattr(agent, "_codex_stream_last_event_ts", None) is None
|
||||
):
|
||||
_deadline = min(_deadline, _ttfb_timeout)
|
||||
agent._emit_wait_notice(
|
||||
f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — "
|
||||
f"{int(_elapsed)}s with no response yet (provider may be slow "
|
||||
f"or overloaded; auto-reconnect at {int(_deadline)}s)"
|
||||
)
|
||||
|
||||
_elapsed = time.time() - _call_start
|
||||
@@ -627,6 +642,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
_close_request_client_once("codex_ttfb_kill")
|
||||
except Exception:
|
||||
pass
|
||||
agent._emit_wait_notice(
|
||||
f"⚠ no response from provider in {int(_elapsed)}s — "
|
||||
f"reconnecting..."
|
||||
)
|
||||
agent._touch_activity(
|
||||
f"codex stream killed after {int(_elapsed)}s with no first byte"
|
||||
)
|
||||
@@ -1635,6 +1654,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
|
||||
# Re-resolve reasoning_config for the new fallback model (Closes #21256).
|
||||
# Shared chokepoint: per-model override > global reasoning_effort
|
||||
# (YAML boolean False = disabled). Wrapped in try/except because a
|
||||
# config load failure must not kill the swap.
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_constants import resolve_reasoning_config
|
||||
|
||||
agent.reasoning_config = resolve_reasoning_config(
|
||||
load_config() or {}, agent.model
|
||||
)
|
||||
logger.info(
|
||||
"Fallback %s: reasoning_config resolved: %s",
|
||||
agent.model, agent.reasoning_config,
|
||||
)
|
||||
except Exception as _reasoning_err:
|
||||
logger.debug(
|
||||
"Failed to resolve reasoning_config for fallback %s; keeping current: %s",
|
||||
agent.model, _reasoning_err,
|
||||
)
|
||||
# Keep whatever reasoning_config was active — don't break the fallback swap.
|
||||
|
||||
# Keep the prompt's self-identity in sync with the model actually
|
||||
# answering, so "what model are you?" doesn't report the primary.
|
||||
rewrite_prompt_model_identity(agent, fb_model, fb_provider)
|
||||
@@ -2511,7 +2552,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
and not reasoning_parts
|
||||
and not tool_calls_acc
|
||||
):
|
||||
raise RuntimeError(
|
||||
raise EmptyStreamError(
|
||||
"Provider returned an empty stream with no finish_reason "
|
||||
"(possible upstream error or malformed SSE response)."
|
||||
)
|
||||
@@ -2600,6 +2641,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
works unchanged.
|
||||
"""
|
||||
has_tool_use = False
|
||||
# Zero-event guard parity with the chat_completions path: track
|
||||
# whether the provider delivered ANY stream event. On an eventless
|
||||
# stream the real Anthropic SDK's get_final_message() raises
|
||||
# AssertionError (no message_start ⇒ no final-message snapshot);
|
||||
# OpenAI-compat shims may instead fabricate a contentless Message
|
||||
# with no stop_reason, or return None under ``python -O`` (assert
|
||||
# stripped). Every one of those shapes is normalized below to
|
||||
# EmptyStreamError so the shared _call() retry loop treats it as
|
||||
# transient instead of surfacing a raw AssertionError or a
|
||||
# fabricated "successful" empty turn.
|
||||
saw_stream_event = False
|
||||
|
||||
# Reset stale-stream timer for this attempt
|
||||
last_chunk_time["t"] = time.time()
|
||||
@@ -2627,6 +2679,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
except Exception:
|
||||
pass
|
||||
for event in stream:
|
||||
saw_stream_event = True
|
||||
# Update stale-stream timer on every event so the
|
||||
# outer poll loop knows data is flowing. Without
|
||||
# this, the detector kills healthy long-running
|
||||
@@ -2687,7 +2740,38 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# this return value is discarded anyway.
|
||||
if agent._interrupt_requested:
|
||||
return None
|
||||
return stream.get_final_message()
|
||||
# Zero-event guard (parity with the chat_completions zero-chunk
|
||||
# guard above). Real SDK: an eventless stream has no
|
||||
# message_start, so get_final_message() raises AssertionError
|
||||
# (final-message snapshot is None) — normalize that to
|
||||
# EmptyStreamError so it gets the transient retry budget
|
||||
# instead of surfacing raw.
|
||||
try:
|
||||
_final_message = stream.get_final_message()
|
||||
except AssertionError:
|
||||
if not saw_stream_event:
|
||||
raise EmptyStreamError(
|
||||
"Provider returned an empty stream with no events "
|
||||
"(possible upstream error or malformed event stream)."
|
||||
) from None
|
||||
raise
|
||||
# Shim variants of the same failure: an OpenAI-compat adapter
|
||||
# may fabricate a contentless Message with no stop_reason, or
|
||||
# return None where the SDK assert would have fired (e.g.
|
||||
# ``python -O``). A real completed response always carries a
|
||||
# stop_reason, so this cannot fire on legitimate turns.
|
||||
if not saw_stream_event and (
|
||||
_final_message is None
|
||||
or (
|
||||
not getattr(_final_message, "content", None)
|
||||
and getattr(_final_message, "stop_reason", None) is None
|
||||
)
|
||||
):
|
||||
raise EmptyStreamError(
|
||||
"Provider returned an empty stream with no stop_reason "
|
||||
"(possible upstream error or malformed event stream)."
|
||||
)
|
||||
return _final_message
|
||||
|
||||
def _call():
|
||||
import httpx as _httpx
|
||||
@@ -2734,6 +2818,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError)
|
||||
)
|
||||
_is_stream_parse_err = agent._is_provider_stream_parse_error(e)
|
||||
_is_empty_stream = isinstance(e, EmptyStreamError)
|
||||
|
||||
# If the stream died AFTER some tokens were delivered:
|
||||
# normally we don't retry (the user already saw text,
|
||||
@@ -2876,7 +2961,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
for phrase in _SSE_CONN_PHRASES
|
||||
)
|
||||
|
||||
if _is_timeout or _is_conn_err or _is_sse_conn_err or _is_stream_parse_err:
|
||||
if (
|
||||
_is_timeout
|
||||
or _is_conn_err
|
||||
or _is_sse_conn_err
|
||||
or _is_stream_parse_err
|
||||
or _is_empty_stream
|
||||
):
|
||||
# Transient network / timeout error. Retry the
|
||||
# streaming request with a fresh connection first.
|
||||
if _stream_attempt < _max_stream_retries:
|
||||
@@ -2919,17 +3010,32 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
mid_tool_call=False,
|
||||
diag=request_client_holder.get("diag"),
|
||||
)
|
||||
agent._buffer_status(
|
||||
"❌ Provider returned malformed streaming data after "
|
||||
f"{_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
"try again in a moment."
|
||||
if _is_stream_parse_err else
|
||||
"❌ Connection to provider failed after "
|
||||
f"{_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
"try again in a moment."
|
||||
)
|
||||
if _is_stream_parse_err:
|
||||
_exhausted_msg = (
|
||||
"❌ Provider returned malformed streaming data after "
|
||||
f"{_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
"try again in a moment."
|
||||
)
|
||||
elif _is_empty_stream:
|
||||
# The connection SUCCEEDED (stream opened) but the
|
||||
# provider sent no chunks — saying "connection
|
||||
# failed" here sends users chasing network issues
|
||||
# when the problem is the provider/endpoint.
|
||||
_exhausted_msg = (
|
||||
"❌ Provider returned an empty response stream "
|
||||
f"after {_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
"try again in a moment."
|
||||
)
|
||||
else:
|
||||
_exhausted_msg = (
|
||||
"❌ Connection to provider failed after "
|
||||
f"{_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
"try again in a moment."
|
||||
)
|
||||
agent._buffer_status(_exhausted_msg)
|
||||
else:
|
||||
_err_lower = str(e).lower()
|
||||
_is_stream_unsupported = (
|
||||
@@ -3046,9 +3152,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
if _hb_now - _last_heartbeat >= _HEARTBEAT_INTERVAL:
|
||||
_last_heartbeat = _hb_now
|
||||
_waiting_secs = int(_hb_now - last_chunk_time["t"])
|
||||
agent._touch_activity(
|
||||
f"waiting for stream response ({_waiting_secs}s, no chunks yet)"
|
||||
)
|
||||
if _waiting_secs >= _HEARTBEAT_INTERVAL:
|
||||
# No chunks for 30s+ — rewrite the live spinner/status line
|
||||
# so CLI/TUI/Desktop users see WHAT the wait is (slow or
|
||||
# overloaded provider / long thinking pause) instead of an
|
||||
# unexplained generic spinner, and WHEN recovery kicks in.
|
||||
if (
|
||||
_stream_stale_timeout is not None
|
||||
and _stream_stale_timeout != float("inf")
|
||||
):
|
||||
_recovery = f"; auto-reconnect at {int(_stream_stale_timeout)}s"
|
||||
else:
|
||||
_recovery = ""
|
||||
agent._emit_wait_notice(
|
||||
f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — "
|
||||
f"{_waiting_secs}s with no output yet (provider may be "
|
||||
f"slow or overloaded, or the model is thinking{_recovery})"
|
||||
)
|
||||
else:
|
||||
# Chunks are flowing — keep the activity tracker fresh but
|
||||
# leave the live display alone.
|
||||
agent._touch_activity(
|
||||
f"waiting for stream response ({_waiting_secs}s, no chunks yet)"
|
||||
)
|
||||
|
||||
# Detect stale streams: connections kept alive by SSE pings
|
||||
# but delivering no real chunks. Kill the client so the
|
||||
@@ -3091,6 +3217,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# Reset the timer so we don't kill repeatedly while
|
||||
# the inner thread processes the closure.
|
||||
last_chunk_time["t"] = time.time()
|
||||
agent._emit_wait_notice(
|
||||
f"⚠ no output from provider for {int(_stale_elapsed)}s — "
|
||||
f"reconnecting..."
|
||||
)
|
||||
agent._touch_activity(
|
||||
f"stale stream detected after {int(_stale_elapsed)}s, reconnecting"
|
||||
)
|
||||
|
||||
@@ -1360,6 +1360,45 @@ def _normalize_codex_response(
|
||||
# so the model keeps its chain-of-thought on the retry.
|
||||
final_text = ""
|
||||
|
||||
# ── Reasoning-channel answer salvage (xAI grok) ──────────────
|
||||
# grok-4.x on the xAI /v1/responses surface sometimes emits its final
|
||||
# answer inside the reasoning item instead of as a ``message`` output
|
||||
# item, marking where the answer starts with grok's internal
|
||||
# ``<response>`` delimiter. Without salvage, the reasoning-only rule
|
||||
# below classifies the turn ``incomplete`` — and because reasoning
|
||||
# items on this surface carry no ``encrypted_content``, the interim
|
||||
# message replays as nothing, so every continuation request is
|
||||
# byte-identical to the one that just failed. The turn burns its 3
|
||||
# retries and dies with "Codex response remained incomplete after 3
|
||||
# continuation attempts" even though the answer was produced on the
|
||||
# first attempt. Observed live with grok-4.20 on xai-oauth
|
||||
# (2026-07-13). Promote the delimited tail to assistant content and
|
||||
# keep the untagged prefix as thinking text.
|
||||
if (
|
||||
issuer_kind == "xai_responses"
|
||||
and not final_text
|
||||
and not tool_calls
|
||||
and reasoning_parts
|
||||
):
|
||||
joined_reasoning = "\n\n".join(reasoning_parts)
|
||||
marker = joined_reasoning.rfind("<response>")
|
||||
if marker != -1:
|
||||
salvaged = joined_reasoning[marker + len("<response>"):]
|
||||
closing = salvaged.find("</response>")
|
||||
if closing != -1:
|
||||
salvaged = salvaged[:closing]
|
||||
salvaged = salvaged.strip()
|
||||
if salvaged:
|
||||
logger.warning(
|
||||
"xAI response delivered its final answer inside the "
|
||||
"reasoning channel (<response> delimiter); promoting "
|
||||
"%d chars to assistant content.",
|
||||
len(salvaged),
|
||||
)
|
||||
final_text = salvaged
|
||||
reasoning_prefix = joined_reasoning[:marker].strip()
|
||||
reasoning_parts = [reasoning_prefix] if reasoning_prefix else []
|
||||
|
||||
assistant_message = SimpleNamespace(
|
||||
content=final_text,
|
||||
tool_calls=tool_calls,
|
||||
@@ -1380,12 +1419,28 @@ def _normalize_codex_response(
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
# human-readable summary) with no visible content or tool calls. The
|
||||
# model is still thinking and needs another turn to produce the actual
|
||||
# answer. Marking this as "stop" would send it into the empty-content
|
||||
# retry loop which burns retries then fails — treat it as incomplete so
|
||||
# the Codex continuation path handles it correctly.
|
||||
finish_reason = "incomplete"
|
||||
# human-readable summary) with no visible content or tool calls.
|
||||
#
|
||||
# For the specially-handled backends (Codex, xAI, GitHub/Copilot),
|
||||
# reasoning-only with status="completed" means "the model is still
|
||||
# thinking and needs another turn" — treat it as incomplete so the
|
||||
# Codex continuation path retries instead of falling into the
|
||||
# empty-content retry loop.
|
||||
#
|
||||
# For all other backends (other:<base_url>, etc.), trust the provider's
|
||||
# own response.status signal. When status == "completed" and no items
|
||||
# are queued/in_progress/incomplete, reasoning alone is a valid final
|
||||
# state — forcing "incomplete" causes multi-minute stalls as the
|
||||
# continuation path re-issues calls (3 retries × up to 240s each).
|
||||
# See https://github.com/NousResearch/hermes-agent/issues/64434
|
||||
if response_status == "completed" and issuer_kind not in (
|
||||
"codex_backend",
|
||||
"xai_responses",
|
||||
"github_responses",
|
||||
):
|
||||
finish_reason = "stop"
|
||||
else:
|
||||
finish_reason = "incomplete"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
return assistant_message, finish_reason
|
||||
|
||||
@@ -282,7 +282,14 @@ def _record_codex_app_server_compaction(
|
||||
# The app server has already completed a real compaction boundary. Its
|
||||
# usage update (when supplied) is therefore the same real-vs-real
|
||||
# effectiveness verdict used by the normal compression path.
|
||||
if hasattr(compressor, "_verify_compaction_cleared_threshold"):
|
||||
record_boundary = getattr(
|
||||
type(compressor), "record_completed_compaction", None
|
||||
)
|
||||
if callable(record_boundary):
|
||||
# Codex owns this summary. A prior Hermes deterministic-fallback
|
||||
# flag must not leak into the native boundary's quality verdict.
|
||||
record_boundary(compressor, used_fallback=False)
|
||||
elif hasattr(compressor, "_verify_compaction_cleared_threshold"):
|
||||
compressor._verify_compaction_cleared_threshold = True
|
||||
if not getattr(turn, "token_usage_last", None):
|
||||
compressor.last_prompt_tokens = -1
|
||||
|
||||
+149
-16
@@ -586,6 +586,21 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
|
||||
return result if changed else messages
|
||||
|
||||
|
||||
def _str_arg(args: dict, key: str, default: str = "") -> str:
|
||||
"""Safely get a string argument from parsed tool args.
|
||||
|
||||
LLMs sometimes return non-string parameter values (e.g. bool, int) for
|
||||
tool calls. Calling ``len()`` / ``.count()`` / slicing on those causes
|
||||
``TypeError`` / ``AttributeError`` which crashes context compression.
|
||||
This helper coerces any value to ``str`` so downstream code can assume
|
||||
a string is always returned.
|
||||
"""
|
||||
val = args.get(key, default)
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
return str(val) if val is not None else default
|
||||
|
||||
|
||||
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
|
||||
"""Create an informative 1-line summary of a tool call + result.
|
||||
|
||||
@@ -598,18 +613,37 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
|
||||
[terminal] ran `npm test` -> exit 0, 47 lines output
|
||||
[read_file] read config.py from line 1 (1,200 chars)
|
||||
[search_files] content search for 'compress' in agent/ -> 12 matches
|
||||
|
||||
Never raises: models sometimes emit non-string argument values (bool,
|
||||
int, None) and the args here come from persisted session history, so a
|
||||
single malformed historical call must not crash compression — which
|
||||
retries on the same history and would crash-loop. Individual branches
|
||||
coerce the values they slice/measure (keeping summaries informative);
|
||||
this wrapper is the backstop for anything they miss.
|
||||
"""
|
||||
try:
|
||||
return _summarize_tool_result_unguarded(tool_name, tool_args, tool_content)
|
||||
except Exception as exc: # noqa: BLE001 — a summary must never crash compression
|
||||
logger.debug("Tool-result summary failed for %s: %s", tool_name, exc)
|
||||
_len = len(tool_content) if isinstance(tool_content, str) else 0
|
||||
return f"[{tool_name}] ({_len:,} chars result)"
|
||||
|
||||
|
||||
def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_content: str) -> str:
|
||||
"""Build the summary line (unguarded; see ``_summarize_tool_result``)."""
|
||||
try:
|
||||
args = json.loads(tool_args) if tool_args else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
|
||||
content = tool_content or ""
|
||||
content_len = len(content)
|
||||
line_count = content.count("\n") + 1 if content.strip() else 0
|
||||
|
||||
if tool_name == "terminal":
|
||||
cmd = args.get("command", "")
|
||||
cmd = _str_arg(args, "command")
|
||||
if len(cmd) > 80:
|
||||
cmd = cmd[:77] + "..."
|
||||
exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content)
|
||||
@@ -623,7 +657,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
|
||||
|
||||
if tool_name == "write_file":
|
||||
path = args.get("path", "?")
|
||||
written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?"
|
||||
written_lines = _str_arg(args, "content").count("\n") + 1 if args.get("content") else "?"
|
||||
return f"[write_file] wrote to {path} ({written_lines} lines)"
|
||||
|
||||
if tool_name == "search_files":
|
||||
@@ -658,14 +692,15 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
|
||||
return f"[web_extract] {url_desc} ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "delegate_task":
|
||||
goal = args.get("goal", "")
|
||||
goal = _str_arg(args, "goal")
|
||||
if len(goal) > 60:
|
||||
goal = goal[:57] + "..."
|
||||
return f"[delegate_task] '{goal}' ({content_len:,} chars result)"
|
||||
|
||||
if tool_name == "execute_code":
|
||||
code_preview = (args.get("code") or "")[:60].replace("\n", " ")
|
||||
if len(args.get("code", "")) > 60:
|
||||
code_str = _str_arg(args, "code")
|
||||
code_preview = code_str[:60].replace("\n", " ")
|
||||
if len(code_str) > 60:
|
||||
code_preview += "..."
|
||||
return f"[execute_code] `{code_preview}` ({line_count} lines output)"
|
||||
|
||||
@@ -674,7 +709,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
|
||||
return f"[{tool_name}] name={name} ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "vision_analyze":
|
||||
question = args.get("question", "")[:50]
|
||||
question = _str_arg(args, "question")[:50]
|
||||
return f"[vision_analyze] '{question}' ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "memory":
|
||||
@@ -736,6 +771,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._fallback_compression_streak = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
@@ -773,6 +809,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._fallback_compression_streak = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
@@ -790,12 +827,84 @@ class ContextCompressor(ContextEngine):
|
||||
self._session_id = session_id or ""
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_summary_error = None
|
||||
self._fallback_compression_streak = 0
|
||||
self.get_active_compression_failure_cooldown()
|
||||
self._load_fallback_compression_streak()
|
||||
|
||||
def on_session_start(self, session_id: str, **kwargs) -> None:
|
||||
"""Bind session-scoped compression state for a new or resumed session."""
|
||||
super().on_session_start(session_id, **kwargs)
|
||||
self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id)
|
||||
boundary_reason = kwargs.get("boundary_reason")
|
||||
old_session_id = kwargs.get("old_session_id")
|
||||
session_db = kwargs.get("session_db", getattr(self, "_session_db", None))
|
||||
previous_fallback_streak = self._fallback_compression_streak
|
||||
if boundary_reason == "compression" and old_session_id:
|
||||
getter = getattr(session_db, "get_compression_fallback_streak", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
stored_streak = getter(old_session_id)
|
||||
if isinstance(stored_streak, (int, float, str)):
|
||||
previous_fallback_streak = max(0, int(stored_streak))
|
||||
except (TypeError, ValueError, sqlite3.Error) as exc:
|
||||
logger.debug("compression parent fallback streak lookup failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"compression parent fallback streak lookup failed (non-sqlite): %s",
|
||||
exc,
|
||||
)
|
||||
self.bind_session_state(session_db, session_id)
|
||||
if boundary_reason == "compression":
|
||||
# Rotation creates a fresh child row before this callback. Preserve
|
||||
# the logical conversation's streak until boundary bookkeeping
|
||||
# persists the updated value onto the child row.
|
||||
self._fallback_compression_streak = previous_fallback_streak
|
||||
|
||||
def _load_fallback_compression_streak(self) -> None:
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
session_id = getattr(self, "_session_id", "")
|
||||
getter = getattr(session_db, "get_compression_fallback_streak", None)
|
||||
if not session_id or not callable(getter):
|
||||
return
|
||||
try:
|
||||
stored_streak = getter(session_id)
|
||||
self._fallback_compression_streak = max(
|
||||
0,
|
||||
int(stored_streak)
|
||||
if isinstance(stored_streak, (int, float, str))
|
||||
else 0,
|
||||
)
|
||||
except (TypeError, ValueError, sqlite3.Error) as exc:
|
||||
logger.debug("compression fallback streak lookup failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc)
|
||||
|
||||
def _persist_fallback_compression_streak(self) -> None:
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
session_id = getattr(self, "_session_id", "")
|
||||
setter = getattr(session_db, "set_compression_fallback_streak", None)
|
||||
if not session_id or not callable(setter):
|
||||
return
|
||||
try:
|
||||
setter(session_id, self._fallback_compression_streak)
|
||||
except sqlite3.Error as exc:
|
||||
logger.debug("compression fallback streak persist failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc)
|
||||
|
||||
def record_completed_compaction(self, *, used_fallback: bool = False) -> None:
|
||||
"""Record one completed boundary and its summary quality."""
|
||||
self._verify_compaction_cleared_threshold = True
|
||||
if used_fallback:
|
||||
self._fallback_compression_streak += 1
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compaction completed with a deterministic fallback summary. "
|
||||
"fallback_compression_streak=%d",
|
||||
self._fallback_compression_streak,
|
||||
)
|
||||
elif self._fallback_compression_streak:
|
||||
self._fallback_compression_streak = 0
|
||||
self._persist_fallback_compression_streak()
|
||||
|
||||
def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the live compression-failure cooldown for the bound session."""
|
||||
@@ -893,6 +1002,12 @@ class ContextCompressor(ContextEngine):
|
||||
max_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Update model info after a model switch or fallback activation."""
|
||||
runtime_changed = any((
|
||||
model != self.model,
|
||||
provider != self.provider,
|
||||
base_url != self.base_url,
|
||||
api_mode != self.api_mode,
|
||||
))
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
@@ -948,6 +1063,9 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
self._ineffective_compression_count = 0
|
||||
if runtime_changed:
|
||||
self._fallback_compression_streak = 0
|
||||
self._persist_fallback_compression_streak()
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
|
||||
@@ -1137,6 +1255,10 @@ class ContextCompressor(ContextEngine):
|
||||
# Anti-thrashing: track whether last compression was effective
|
||||
self._last_compression_savings_pct: float = 100.0
|
||||
self._ineffective_compression_count: int = 0
|
||||
# Consecutive completed deterministic-fallback boundaries. Unlike the
|
||||
# real-usage effectiveness counter, ordinary fitting responses must not
|
||||
# reset this breaker; only a healthy completed summary does.
|
||||
self._fallback_compression_streak: int = 0
|
||||
# Set after a completed compression boundary; consumed by the next
|
||||
# provider-reported prompt count in update_from_response().
|
||||
self._verify_compaction_cleared_threshold: bool = False
|
||||
@@ -1190,8 +1312,10 @@ class ContextCompressor(ContextEngine):
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
# Any real provider reading below the trigger proves the prompt
|
||||
# fits again. Clear the episode latch even when this response was
|
||||
# not the one immediately following compaction.
|
||||
# fits again. Clear the real-usage effectiveness latch even
|
||||
# when this response was not immediately after compaction. The
|
||||
# independent fallback streak is boundary-scoped and survives
|
||||
# ordinary fitting responses during context regrowth.
|
||||
self._ineffective_compression_count = 0
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
@@ -1285,6 +1409,10 @@ class ContextCompressor(ContextEngine):
|
||||
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
|
||||
if tokens < self.threshold_tokens:
|
||||
return False
|
||||
return not self._automatic_compression_blocked()
|
||||
|
||||
def _automatic_compression_blocked(self) -> bool:
|
||||
"""Return whether automatic compaction is in cooldown or tripped."""
|
||||
# Do not trigger compression while the summary LLM is in cooldown.
|
||||
# On a 429/transient failure _generate_summary() sets a cooldown and
|
||||
# returns None; compress() then inserts a static fallback marker and
|
||||
@@ -1301,18 +1429,23 @@ class ContextCompressor(ContextEngine):
|
||||
"Compression deferred — summary LLM in cooldown for %.0fs more",
|
||||
_cooldown_remaining,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
# Anti-thrashing: back off if recent compressions were ineffective
|
||||
if self._ineffective_compression_count >= 2:
|
||||
if (
|
||||
self._ineffective_compression_count >= 2
|
||||
or self._fallback_compression_streak >= 2
|
||||
):
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compression skipped — last %d compaction attempts did not "
|
||||
"restore enough context headroom. Consider /new to start a "
|
||||
"fresh session, or /compress <topic> for focused compression.",
|
||||
"Compression skipped — repeated compaction attempts did not "
|
||||
"restore healthy context. ineffective=%d fallback=%d. "
|
||||
"Consider /new to start fresh, or /compress <topic> for "
|
||||
"focused compression.",
|
||||
self._ineffective_compression_count,
|
||||
self._fallback_compression_streak,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool output pruning (cheap pre-pass, no LLM call)
|
||||
|
||||
@@ -28,6 +28,7 @@ these paths see no behavioural change.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -52,6 +53,29 @@ COMPACTION_STATUS = (
|
||||
)
|
||||
|
||||
|
||||
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
|
||||
"""Whether the live in-memory SessionDB class structurally predates locks.
|
||||
|
||||
In the supported hot-reload skew, this module is new while the already
|
||||
imported ``hermes_state.SessionDB`` class (and its live instances) is old.
|
||||
Only that exact class identity may fail open. Proxies, nominal lookalikes,
|
||||
non-callables, and descriptor failures must fail closed. Static lookup
|
||||
avoids invoking a present-but-broken descriptor.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
missing = object()
|
||||
return (
|
||||
type(lock_db) is SessionDB
|
||||
and inspect.getattr_static(
|
||||
SessionDB, "try_acquire_compression_lock", missing
|
||||
) is missing
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
@@ -480,6 +504,21 @@ def compress_context(
|
||||
force=force,
|
||||
)
|
||||
|
||||
# Every automatic entrypoint must honor compressor-owned cooldown and
|
||||
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
|
||||
# persisted fallback streak is loaded by bind_session_state() before this.
|
||||
if not force:
|
||||
blocked = getattr(
|
||||
type(agent.context_compressor),
|
||||
"_automatic_compression_blocked",
|
||||
None,
|
||||
)
|
||||
if callable(blocked) and blocked(agent.context_compressor):
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
# Lazy feasibility check — run the auxiliary-provider probe + context
|
||||
# length lookup just-in-time on the first compression attempt instead of
|
||||
# at AIAgent.__init__. Saves ~400ms cold off every short session that
|
||||
@@ -541,21 +580,31 @@ def compress_context(
|
||||
_lock_sid = agent.session_id or ""
|
||||
_lock_holder: Optional[str] = None
|
||||
# Probe whether the lock subsystem is actually available on this
|
||||
# SessionDB instance. A process running mismatched module versions
|
||||
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
|
||||
# long-lived ``hermes_state.SessionDB`` class still bound to the
|
||||
# pre-#34351 version in memory) has the call site but not the method.
|
||||
# In that case ``try_acquire_compression_lock`` raises AttributeError —
|
||||
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
|
||||
# runs and the exception propagates to the outer agent loop, which
|
||||
# prints the error and retries. Because compression never succeeds,
|
||||
# the token count never drops and the loop re-triggers compaction
|
||||
# forever (the "API call #47/#48/#49 ... has no attribute
|
||||
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
|
||||
# subsystem is missing or broken in any unexpected way, skip locking
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
# SessionDB instance. A process running mismatched module versions can have
|
||||
# this call site while its long-lived SessionDB instance predates the lock
|
||||
# API. Only that structural absence is safe to fail open for: compression
|
||||
# must make progress rather than spin forever after an update. Once the
|
||||
# method has been resolved, every exception from its implementation fails
|
||||
# closed because proceeding without a lock can fork the session lineage.
|
||||
_try_acquire_lock = None
|
||||
_lock_lookup_error: Optional[Exception] = None
|
||||
_legacy_session_db_without_lock_api = False
|
||||
if _lock_db is not None:
|
||||
try:
|
||||
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
|
||||
_lock_db
|
||||
)
|
||||
except Exception as exc:
|
||||
_lock_lookup_error = exc
|
||||
if _lock_lookup_error is None and not _legacy_session_db_without_lock_api:
|
||||
try:
|
||||
_try_acquire_lock = _lock_db.try_acquire_compression_lock
|
||||
if not callable(_try_acquire_lock):
|
||||
_lock_lookup_error = TypeError(
|
||||
"compression lock API is present but not callable"
|
||||
)
|
||||
except Exception as exc:
|
||||
_lock_lookup_error = exc
|
||||
try:
|
||||
_lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
|
||||
except (TypeError, ValueError):
|
||||
@@ -564,25 +613,58 @@ def compress_context(
|
||||
_lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
if _lock_lookup_error is not None:
|
||||
# Attribute lookup itself failed for a reason other than a missing
|
||||
# lock API. It is unsafe to proceed without a lock in that case.
|
||||
_lock_holder = None
|
||||
logger.warning(
|
||||
"compression lock lookup raised unexpectedly for session=%s "
|
||||
"(%s: %s) — skipping compression this cycle",
|
||||
_lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error,
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
# per session and proceed WITHOUT the lock rather than letting
|
||||
# the exception spin the outer loop.
|
||||
_lock_holder = None # we don't own anything to release
|
||||
_lock_acquired = False
|
||||
elif _try_acquire_lock is None:
|
||||
# The lock API itself is absent on this in-memory instance. Log once
|
||||
# and proceed unlocked so an update-version skew cannot leave the
|
||||
# outer auto-compression loop making no progress forever.
|
||||
_lock_holder = None
|
||||
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_error_sid = _lock_sid
|
||||
logger.warning(
|
||||
"compression lock subsystem unavailable for session=%s "
|
||||
"(%s: %s) — proceeding without lock. This usually means a "
|
||||
"stale in-memory module after an update; restart the "
|
||||
"process (or `hermes update`) to resync.",
|
||||
"— proceeding without lock. This usually means a stale "
|
||||
"in-memory module after an update; restart the process "
|
||||
"(or `hermes update`) to resync.",
|
||||
_lock_sid,
|
||||
)
|
||||
_lock_acquired = True # acquired-but-unlocked compatibility path
|
||||
else:
|
||||
try:
|
||||
_lock_acquired = _try_acquire_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# The method exists and entered its implementation but failed.
|
||||
# Do not mistake an internal AttributeError or TypeError for
|
||||
# version skew: fail closed and preserve session lineage. A
|
||||
# failure after SQLite committed the acquire can leave our
|
||||
# holder row behind, so release it best-effort before returning
|
||||
# unchanged messages; release is holder-qualified and safe when
|
||||
# acquisition never succeeded.
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
except Exception as _release_err:
|
||||
logger.debug(
|
||||
"compression lock cleanup after failed acquire failed: %s",
|
||||
_release_err,
|
||||
)
|
||||
_lock_holder = None
|
||||
logger.warning(
|
||||
"compression lock acquisition raised unexpectedly for "
|
||||
"session=%s (%s: %s) — skipping compression this cycle",
|
||||
_lock_sid, type(_lock_err).__name__, _lock_err,
|
||||
)
|
||||
_lock_acquired = True # treat as acquired-but-unlocked; proceed
|
||||
_lock_acquired = False
|
||||
if not _lock_acquired:
|
||||
try:
|
||||
existing = _lock_db.get_compression_lock_holder(_lock_sid)
|
||||
@@ -651,6 +733,17 @@ def compress_context(
|
||||
_release_lock()
|
||||
raise
|
||||
|
||||
# Capture boundary quality before session-rotation callbacks run. Built-in
|
||||
# and plugin lifecycle hooks may reset per-session compressor fields while
|
||||
# rebinding to the child id; the completed attempt's verdict must survive
|
||||
# that rebind and be recorded only after the full boundary commits.
|
||||
_compression_made_progress = bool(
|
||||
getattr(agent.context_compressor, "_last_compression_made_progress", False)
|
||||
)
|
||||
_compression_used_fallback = bool(
|
||||
getattr(agent.context_compressor, "_last_summary_fallback_used", False)
|
||||
)
|
||||
|
||||
# If compression aborted (aux LLM failed to produce a usable summary)
|
||||
# the compressor returns the input messages unchanged. Surface the
|
||||
# error to the user, skip the session-rotation work entirely (no
|
||||
@@ -979,8 +1072,19 @@ def compress_context(
|
||||
# the full compaction boundary. Exceptions, aborts, and no-op attempts
|
||||
# leave this false, so unrelated later usage cannot be charged to an
|
||||
# attempt that never changed the transcript.
|
||||
if getattr(agent.context_compressor, "_last_compression_made_progress", False):
|
||||
agent.context_compressor._verify_compaction_cleared_threshold = True
|
||||
if _compression_made_progress:
|
||||
record_boundary = getattr(
|
||||
type(agent.context_compressor),
|
||||
"record_completed_compaction",
|
||||
None,
|
||||
)
|
||||
if callable(record_boundary):
|
||||
record_boundary(
|
||||
agent.context_compressor,
|
||||
used_fallback=_compression_used_fallback,
|
||||
)
|
||||
else:
|
||||
agent.context_compressor._verify_compaction_cleared_threshold = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
|
||||
+193
-18
@@ -194,7 +194,6 @@ def _is_nous_inference_route(provider: str, base_url: str) -> bool:
|
||||
base = str(base_url or "")
|
||||
return (
|
||||
base_url_host_matches(base, "inference-api.nousresearch.com")
|
||||
or base_url_host_matches(base, "inference.nousresearch.com")
|
||||
)
|
||||
|
||||
|
||||
@@ -458,6 +457,21 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
|
||||
)
|
||||
|
||||
|
||||
# Continuation nudge for Codex/Responses turns that came back with only
|
||||
# internal reasoning (no visible content, no tool calls). When the interim
|
||||
# assistant message also carries no encrypted reasoning items and no
|
||||
# replayable message items, _chat_messages_to_responses_input emits nothing
|
||||
# for it — a bare retry would be byte-identical to the request that just
|
||||
# failed, so the model (observed: grok-4.20 on xai-oauth) deterministically
|
||||
# repeats the reasoning-only response until the retry budget is exhausted.
|
||||
_CODEX_INCOMPLETE_NUDGE = (
|
||||
"[System: Your previous response contained only internal reasoning and "
|
||||
"never produced a visible answer or tool call. Do not keep thinking. "
|
||||
"Produce your final answer as plain text now (or make the tool call "
|
||||
"you were planning).]"
|
||||
)
|
||||
|
||||
|
||||
# Shared recovery hint appended to every content-policy refusal message. Both
|
||||
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
|
||||
# exception path (a provider moderation error classified as
|
||||
@@ -522,12 +536,12 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
|
||||
|
||||
def run_conversation(
|
||||
agent,
|
||||
user_message: str,
|
||||
user_message: Any,
|
||||
system_message: str = None,
|
||||
conversation_history: List[Dict[str, Any]] = None,
|
||||
task_id: str = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
persist_user_message: Optional[Any] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
moa_config: Optional[dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -857,10 +871,19 @@ def run_conversation(
|
||||
|
||||
if moa_config:
|
||||
try:
|
||||
from agent.message_content import flatten_message_text as _flatten_mt
|
||||
from agent.moa_loop import _preset_temperature, aggregate_moa_context
|
||||
|
||||
_moa_context = aggregate_moa_context(
|
||||
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
|
||||
user_prompt=(
|
||||
original_user_message
|
||||
if isinstance(original_user_message, str)
|
||||
# Multimodal / decorated content list: extract the
|
||||
# visible text instead of str()-ing a Python repr of
|
||||
# the parts (which would leak base64 image payloads
|
||||
# into the aggregator prompt).
|
||||
else _flatten_mt(original_user_message)
|
||||
),
|
||||
api_messages=api_messages,
|
||||
reference_models=moa_config.get("reference_models") or [],
|
||||
aggregator=moa_config.get("aggregator") or {},
|
||||
@@ -874,6 +897,14 @@ def run_conversation(
|
||||
_base = _msg.get("content", "")
|
||||
if isinstance(_base, str):
|
||||
_msg["content"] = _base + "\n\n" + _moa_context
|
||||
elif isinstance(_base, list):
|
||||
# Multimodal user turn (text + image parts):
|
||||
# append the MoA context as a trailing text
|
||||
# part instead of silently dropping it.
|
||||
_msg["content"] = [
|
||||
*_base,
|
||||
{"type": "text", "text": "\n\n" + _moa_context},
|
||||
]
|
||||
break
|
||||
except Exception as _moa_exc:
|
||||
logger.warning("MoA context aggregation failed: %s", _moa_exc)
|
||||
@@ -3061,14 +3092,24 @@ def run_conversation(
|
||||
# (``/new``), switch to a larger-context model, or reduce
|
||||
# attachments. Forced compaction via ``/compress``
|
||||
# (``force=True``) is unaffected — it never reaches this loop.
|
||||
#
|
||||
# Output-cap errors (max_tokens too large) are NOT input
|
||||
# overflow — the recovery is a max_tokens-only retry that
|
||||
# does not require compression. Exempt them from this guard
|
||||
# so the retry still fires even when compression is disabled.
|
||||
_overflow_reasons = {
|
||||
FailoverReason.long_context_tier,
|
||||
FailoverReason.payload_too_large,
|
||||
FailoverReason.context_overflow,
|
||||
}
|
||||
_is_output_cap_error = (
|
||||
is_output_cap_error(error_msg)
|
||||
or parse_available_output_tokens_from_error(error_msg) is not None
|
||||
)
|
||||
if (
|
||||
classified.reason in _overflow_reasons
|
||||
and not getattr(agent, "compression_enabled", True)
|
||||
and not _is_output_cap_error
|
||||
):
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(
|
||||
@@ -3472,15 +3513,33 @@ def run_conversation(
|
||||
# context_length = total window (input + output combined).
|
||||
available_out = parse_available_output_tokens_from_error(error_msg)
|
||||
if available_out is not None:
|
||||
# Error is purely about the output cap being too large.
|
||||
# Cap output to the available space and retry without
|
||||
# touching context_length or triggering compression.
|
||||
safe_out = max(1, available_out - 64) # small safety margin
|
||||
# This is an output-cap error, not input overflow.
|
||||
# The provider's available_tokens is the authoritative
|
||||
# cap for the failed request, so keep it as an upper
|
||||
# bound. Also estimate the current API request shape
|
||||
# (system prompt, injected context, tool schemas) because
|
||||
# Hermes may add API-only content not present in persisted
|
||||
# messages. Use the smaller budget and apply a small
|
||||
# safety margin. Do not alter context_length.
|
||||
request_input_estimate = estimate_request_tokens_rough(
|
||||
api_messages, tools=agent.tools or None,
|
||||
)
|
||||
local_available_out = old_ctx - request_input_estimate
|
||||
if local_available_out > 0:
|
||||
safe_out = max(1, min(available_out, local_available_out) - 64)
|
||||
else:
|
||||
# The rough local estimate can overshoot the real
|
||||
# request size. Fall back to the provider-reported
|
||||
# budget, which is authoritative for the failed
|
||||
# request.
|
||||
safe_out = max(1, available_out - 64)
|
||||
agent._ephemeral_max_output_tokens = safe_out
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Output cap too large for current prompt — "
|
||||
f"retrying with max_tokens={safe_out:,} "
|
||||
f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})"
|
||||
f"(provider_available={available_out:,}, "
|
||||
f"estimated_request_tokens={request_input_estimate:,}; "
|
||||
f"context_length unchanged at {old_ctx:,})"
|
||||
)
|
||||
# Still count against compression_attempts so we don't
|
||||
# loop forever if the error keeps recurring.
|
||||
@@ -4425,8 +4484,56 @@ def run_conversation(
|
||||
agent._emit_interim_assistant_message(interim_msg)
|
||||
|
||||
if agent._codex_incomplete_retries < 3:
|
||||
# When the interim message has nothing the Responses
|
||||
# input converter will replay (no visible content, no
|
||||
# encrypted reasoning items, no replayable message
|
||||
# items — plain-text reasoning only), a bare retry is
|
||||
# byte-identical to the request that just came back
|
||||
# incomplete and fails the same way every time
|
||||
# (observed with grok-4.20 on xai-oauth, whose
|
||||
# reasoning items lack encrypted_content). Append a
|
||||
# user-role nudge so the retry actually differs and
|
||||
# explicitly asks for the final answer.
|
||||
interim_replayable = (
|
||||
interim_has_content
|
||||
or interim_has_codex_reasoning
|
||||
or interim_has_codex_message_items
|
||||
)
|
||||
if not interim_replayable:
|
||||
_last_msg = messages[-1] if messages else None
|
||||
_already_nudged = (
|
||||
isinstance(_last_msg, dict)
|
||||
and _last_msg.get("role") == "user"
|
||||
and _last_msg.get("content") == _CODEX_INCOMPLETE_NUDGE
|
||||
)
|
||||
# Alternation guard: the nudge is a user-role message,
|
||||
# so it may only follow an assistant message. When the
|
||||
# interim was too empty to append (no content AND no
|
||||
# reasoning), the last message is still the prior
|
||||
# user/tool turn — appending the nudge there would
|
||||
# create a user→user / tool→user sequence that strict
|
||||
# providers reject.
|
||||
_last_is_assistant = (
|
||||
isinstance(_last_msg, dict)
|
||||
and _last_msg.get("role") == "assistant"
|
||||
)
|
||||
if not _already_nudged and _last_is_assistant:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _CODEX_INCOMPLETE_NUDGE,
|
||||
})
|
||||
if not agent.quiet_mode:
|
||||
agent._vprint(f"{agent.log_prefix}↻ Codex response incomplete; continuing turn ({agent._codex_incomplete_retries}/3)")
|
||||
# Surface the continuation on the live spinner/status line
|
||||
# (CLI/TUI/Desktop) and gateway heartbeat: each of these
|
||||
# retries can spend minutes waiting on the provider, and
|
||||
# without a distinct notice the user only sees a generic
|
||||
# thinking spinner ("infinite thinking", #64434).
|
||||
agent._emit_wait_notice(
|
||||
f"↻ model returned reasoning with no final answer — "
|
||||
f"asking it to continue "
|
||||
f"({agent._codex_incomplete_retries}/3)"
|
||||
)
|
||||
agent._session_messages = messages
|
||||
continue
|
||||
|
||||
@@ -4450,7 +4557,9 @@ def run_conversation(
|
||||
|
||||
if agent.verbose_logging:
|
||||
for tc in assistant_message.tool_calls:
|
||||
logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...")
|
||||
raw_args = tc.function.arguments
|
||||
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
|
||||
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
|
||||
|
||||
# Validate tool call names - detect model hallucinations
|
||||
# Repair mismatched tool names before validating
|
||||
@@ -4631,11 +4740,37 @@ def run_conversation(
|
||||
|
||||
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
|
||||
turn_content = assistant_message.content or ""
|
||||
|
||||
# Classify tools in this turn to determine if they are all housekeeping.
|
||||
# This classification is needed regardless of whether the turn has visible content,
|
||||
# because a substantive tool-only turn must invalidate any older housekeeping fallback.
|
||||
_HOUSEKEEPING_TOOLS = frozenset({
|
||||
"memory", "todo", "skill_manage", "session_search",
|
||||
})
|
||||
_all_housekeeping = all(
|
||||
tc.function.name in _HOUSEKEEPING_TOOLS
|
||||
for tc in assistant_message.tool_calls
|
||||
)
|
||||
|
||||
# If this turn has substantive tools (non-housekeeping), clear any older fallback.
|
||||
# Prevents a two-turn-old housekeeping narration from being treated as if it belonged
|
||||
# to the immediately preceding substantive tool turn.
|
||||
if assistant_message.tool_calls and not _all_housekeeping:
|
||||
agent._last_content_with_tools = None
|
||||
agent._last_content_tools_all_housekeeping = False
|
||||
# Also clear the mute flag: a prior housekeeping turn may
|
||||
# have set _mute_post_response (line ~4667), and the
|
||||
# substantive tools in THIS turn should produce visible
|
||||
# progress output. Without this reset, _vprint suppresses
|
||||
# tool progress until the no-tool-call branch clears it at
|
||||
# line ~4834 — after all tools have finished.
|
||||
agent._mute_post_response = False
|
||||
|
||||
# If this turn has both content AND tool_calls, capture the content
|
||||
# as a fallback final response. Common pattern: model delivers its
|
||||
# answer and calls memory/skill tools as a side-effect in the same
|
||||
# turn. If the follow-up turn after tools is empty, we use this.
|
||||
turn_content = assistant_message.content or ""
|
||||
if turn_content and agent._has_content_after_think_block(turn_content):
|
||||
agent._last_content_with_tools = turn_content
|
||||
# Only mute subsequent output when EVERY tool call in
|
||||
@@ -4643,13 +4778,6 @@ def run_conversation(
|
||||
# skill_manage, etc.). If any substantive tool is present
|
||||
# (search_files, read_file, write_file, terminal, ...),
|
||||
# keep output visible so the user sees progress.
|
||||
_HOUSEKEEPING_TOOLS = frozenset({
|
||||
"memory", "todo", "skill_manage", "session_search",
|
||||
})
|
||||
_all_housekeeping = all(
|
||||
tc.function.name in _HOUSEKEEPING_TOOLS
|
||||
for tc in assistant_message.tool_calls
|
||||
)
|
||||
agent._last_content_tools_all_housekeeping = _all_housekeeping
|
||||
if _all_housekeeping and agent._has_stream_consumers():
|
||||
agent._mute_post_response = True
|
||||
@@ -5265,6 +5393,53 @@ def run_conversation(
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
# ── Kanban worker terminal-tool stop guard ─────────────
|
||||
# Workers must end with kanban_complete / kanban_block.
|
||||
# Models sometimes narrate the next step ("Let me write the
|
||||
# report") and stop with finish_reason=stop — a clean exit
|
||||
# that the dispatcher records as protocol_violation. Nudge
|
||||
# once or twice before allowing that exit.
|
||||
try:
|
||||
from agent.kanban_stop import build_kanban_stop_nudge
|
||||
|
||||
_kanban_nudge = build_kanban_stop_nudge(
|
||||
messages=messages,
|
||||
attempts=getattr(agent, "_kanban_stop_nudges", 0),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("kanban stop-loop check failed", exc_info=True)
|
||||
_kanban_nudge = None
|
||||
|
||||
if _kanban_nudge:
|
||||
agent._kanban_stop_nudges = (
|
||||
getattr(agent, "_kanban_stop_nudges", 0) + 1
|
||||
)
|
||||
final_msg["finish_reason"] = "kanban_terminal_required"
|
||||
final_msg["_kanban_stop_synthetic"] = True
|
||||
messages.append(final_msg)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _kanban_nudge,
|
||||
"_kanban_stop_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
logger.info(
|
||||
"kanban stop-loop nudge issued (attempt %d) task=%s",
|
||||
agent._kanban_stop_nudges,
|
||||
os.environ.get("HERMES_KANBAN_TASK", ""),
|
||||
)
|
||||
agent._emit_status(
|
||||
"⚠️ Kanban worker tried to exit without "
|
||||
"kanban_complete/kanban_block — nudging to finish"
|
||||
)
|
||||
# Same finalizer contract as verify-on-stop: clear
|
||||
# final_response while continuing so a later budget
|
||||
# exhaustion path does not treat the narrated stop as
|
||||
# a completed answer.
|
||||
_pending_verification_response = final_response
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
||||
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
|
||||
|
||||
@@ -26,7 +26,7 @@ from openai.types.chat.chat_completion_message_tool_call import (
|
||||
Function,
|
||||
)
|
||||
|
||||
from agent.file_safety import get_read_block_error, is_write_denied
|
||||
from agent.file_safety import get_read_block_error, get_write_denied_error
|
||||
from agent.redact import redact_sensitive_text
|
||||
from tools.environments.local import hermes_subprocess_env
|
||||
|
||||
@@ -727,10 +727,9 @@ class CopilotACPClient:
|
||||
elif method == "fs/write_text_file":
|
||||
try:
|
||||
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
|
||||
if is_write_denied(str(path)):
|
||||
raise PermissionError(
|
||||
f"Write denied: '{path}' is a protected system/credential file."
|
||||
)
|
||||
denied = get_write_denied_error(str(path))
|
||||
if denied:
|
||||
raise PermissionError(denied)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(str(params.get("content") or ""))
|
||||
response = {
|
||||
|
||||
+53
-14
@@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
|
||||
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
|
||||
if (
|
||||
provider == "anthropic"
|
||||
and isinstance(token, str)
|
||||
and token.startswith("sk-ant-oat")
|
||||
):
|
||||
return AUTH_TYPE_OAUTH
|
||||
return str(auth_type or AUTH_TYPE_API_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PooledCredential:
|
||||
provider: str
|
||||
@@ -157,6 +168,11 @@ class PooledCredential:
|
||||
def __post_init__(self):
|
||||
if self.extra is None:
|
||||
self.extra = {}
|
||||
self.auth_type = _normalize_pool_auth_type(
|
||||
self.provider,
|
||||
self.access_token,
|
||||
self.auth_type,
|
||||
)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name in _EXTRA_KEYS:
|
||||
@@ -1416,6 +1432,11 @@ class CredentialPool:
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# Borrowed credentials persist as metadata-only references and are
|
||||
# hydrated from their live source on load. A stale duplicate row
|
||||
# can remain unhydrated; never lease or select it as an empty key.
|
||||
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
|
||||
continue
|
||||
# For anthropic claude_code entries, sync from the credentials file
|
||||
# before any status/refresh checks. This picks up tokens refreshed
|
||||
# by other processes (Claude Code CLI, other Hermes profiles).
|
||||
@@ -1732,11 +1753,15 @@ class CredentialPool:
|
||||
|
||||
|
||||
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
|
||||
existing_idx = None
|
||||
matching_indices = []
|
||||
for idx, entry in enumerate(entries):
|
||||
if entry.source == source:
|
||||
existing_idx = idx
|
||||
break
|
||||
matching_indices.append(idx)
|
||||
|
||||
existing_idx = matching_indices[0] if matching_indices else None
|
||||
duplicate_indices = set(matching_indices[1:])
|
||||
if duplicate_indices:
|
||||
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
|
||||
|
||||
if existing_idx is None:
|
||||
payload.setdefault("id", uuid.uuid4().hex[:6])
|
||||
@@ -1768,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
||||
# Runtime-only borrowed secret updates should refresh the in-memory
|
||||
# entry without forcing auth.json churn when the disk-safe payload is
|
||||
# unchanged (for example env keys with the same fingerprint).
|
||||
return existing.to_dict() != updated.to_dict()
|
||||
return False
|
||||
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
|
||||
return bool(duplicate_indices)
|
||||
|
||||
|
||||
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
|
||||
@@ -2243,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
if _is_source_suppressed(provider, source):
|
||||
continue
|
||||
active_sources.add(source)
|
||||
# Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
|
||||
auth_type = (
|
||||
AUTH_TYPE_OAUTH
|
||||
if provider == "anthropic" and token.startswith("sk-ant-oat")
|
||||
else AUTH_TYPE_API_KEY
|
||||
)
|
||||
base_url = env_url or pconfig.inference_base_url
|
||||
if provider == "kimi-coding":
|
||||
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
|
||||
@@ -2263,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
env_var=env_var,
|
||||
token=token,
|
||||
base_url=base_url,
|
||||
auth_type=auth_type,
|
||||
),
|
||||
)
|
||||
return changed, active_sources
|
||||
@@ -2391,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool:
|
||||
for payload in raw_entries
|
||||
)
|
||||
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
|
||||
raw_needs_auth_normalization = any(
|
||||
isinstance(payload, dict)
|
||||
and _normalize_pool_auth_type(
|
||||
provider,
|
||||
payload.get("access_token"),
|
||||
payload.get("auth_type", AUTH_TYPE_API_KEY),
|
||||
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
|
||||
for payload in raw_entries
|
||||
)
|
||||
if raw_needs_auth_normalization:
|
||||
# A profile may be reading this provider from the global-root fallback.
|
||||
# Keep that fallback read-only: only the store that owns these rows may
|
||||
# rewrite them. Loading the default/root profile will heal global rows.
|
||||
active_pool = _load_auth_store().get("credential_pool")
|
||||
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
|
||||
raw_needs_auth_normalization = bool(active_entries)
|
||||
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
# Custom endpoint pool — seed from custom_providers config and model config
|
||||
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
|
||||
changed = raw_needs_sanitization or custom_changed
|
||||
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
|
||||
changed |= _prune_stale_seeded_entries(entries, custom_sources)
|
||||
else:
|
||||
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
||||
env_changed, env_sources = _seed_from_env(provider, entries)
|
||||
changed = raw_needs_sanitization or singleton_changed or env_changed
|
||||
changed = (
|
||||
raw_needs_sanitization
|
||||
or raw_needs_auth_normalization
|
||||
or singleton_changed
|
||||
or env_changed
|
||||
)
|
||||
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
|
||||
# process missing a provider env var must not delete the persisted
|
||||
# pool entry for every other process (#9331). File-backed singletons
|
||||
|
||||
+4
-3
@@ -462,13 +462,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
sid = args.get("session_id", "")
|
||||
data = args.get("data", "")
|
||||
timeout_val = args.get("timeout")
|
||||
parts = [action]
|
||||
parts = [str(action) if action else ""]
|
||||
if sid:
|
||||
parts.append(sid[:16])
|
||||
parts.append(str(sid)[:16])
|
||||
if data:
|
||||
parts.append(f'"{_oneline(data[:20])}"')
|
||||
parts.append(f'"{_oneline(str(data)[:20])}"')
|
||||
if timeout_val and action == "wait":
|
||||
parts.append(f"{timeout_val}s")
|
||||
parts = [p for p in parts if p]
|
||||
return " ".join(parts) if parts else None
|
||||
|
||||
if tool_name == "todo":
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
|
||||
|
||||
class EmptyStreamError(RuntimeError):
|
||||
"""Raised when a provider closes a stream without yielding a response."""
|
||||
|
||||
pass
|
||||
|
||||
+27
-8
@@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]:
|
||||
return roots
|
||||
|
||||
|
||||
def is_write_denied(path: str) -> bool:
|
||||
"""Return True if path is blocked by the write denylist or safe root."""
|
||||
def _classify_write_denial(path: str) -> Optional[str]:
|
||||
"""Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed."""
|
||||
home = os.path.realpath(os.path.expanduser("~"))
|
||||
resolved = os.path.realpath(os.path.expanduser(str(path)))
|
||||
|
||||
if resolved in build_write_denied_paths(home):
|
||||
return True
|
||||
return "credential"
|
||||
for prefix in build_write_denied_prefixes(home):
|
||||
if resolved.startswith(prefix):
|
||||
return True
|
||||
return "credential"
|
||||
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
@@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool:
|
||||
try:
|
||||
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
|
||||
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
|
||||
return True
|
||||
return "credential"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pairing_real = os.path.realpath(os.path.join(base_real, "pairing"))
|
||||
if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):
|
||||
return True
|
||||
return "credential"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool:
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return True
|
||||
return "safe_root"
|
||||
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def is_write_denied(path: str) -> bool:
|
||||
"""Return True if path is blocked by the write denylist or safe root."""
|
||||
return _classify_write_denial(path) is not None
|
||||
|
||||
|
||||
def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]:
|
||||
"""Return a user/model-facing error when writes to ``path`` are blocked."""
|
||||
denial = _classify_write_denial(path)
|
||||
if denial is None:
|
||||
return None
|
||||
if denial == "safe_root":
|
||||
roots_display = os.pathsep.join(sorted(get_safe_write_roots()))
|
||||
return (
|
||||
f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT "
|
||||
f"({roots_display}). Unset the variable or add this path's directory prefix."
|
||||
)
|
||||
return f"{verb} denied: '{path}' is a protected system/credential file."
|
||||
|
||||
|
||||
# Common secret-bearing project-local environment file basenames.
|
||||
|
||||
@@ -32,6 +32,13 @@ from agent.gemini_schema import sanitize_gemini_tool_parameters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import hermes_cli as _hermes_cli
|
||||
|
||||
_HERMES_VERSION = str(_hermes_cli.__version__)
|
||||
except Exception:
|
||||
_HERMES_VERSION = "0.0.0"
|
||||
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
# Published max output-token ceiling shared by every current Gemini text model
|
||||
@@ -99,7 +106,10 @@ def probe_gemini_tier(
|
||||
url,
|
||||
params={"key": key},
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("probe_gemini_tier: network error: %s", exc)
|
||||
@@ -901,7 +911,11 @@ class GeminiNativeClient:
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"x-goog-api-key": self.api_key,
|
||||
"User-Agent": "hermes-agent (gemini-native)",
|
||||
# Include Hermes client context following Gemini's partner
|
||||
# integration guidance.
|
||||
# See https://ai.google.dev/gemini-api/docs/partner-integration
|
||||
"User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)",
|
||||
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
|
||||
}
|
||||
headers.update(self._default_headers)
|
||||
return headers
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Turn-end guard for kanban workers.
|
||||
|
||||
Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models
|
||||
(especially GLM / Qwen families) sometimes narrate the next step
|
||||
("Let me write the report now") and stop with ``finish_reason=stop`` and no
|
||||
tool calls. Hermes treats that as a clean exit → ``rc=0`` → dispatcher
|
||||
``protocol_violation``.
|
||||
|
||||
This module is policy-only: when a kanban worker tries to finish without a
|
||||
terminal board tool, return a bounded synthetic nudge so the conversation
|
||||
loop continues instead of exiting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
|
||||
_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"})
|
||||
|
||||
_DEFAULT_MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
def kanban_stop_nudge_enabled() -> bool:
|
||||
"""Return whether the kanban stop-guard is active for this process.
|
||||
|
||||
On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless
|
||||
``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it.
|
||||
"""
|
||||
env = os.environ.get("HERMES_KANBAN_STOP_NUDGE")
|
||||
if env is not None and env.strip().lower() in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
|
||||
return bool(task)
|
||||
|
||||
|
||||
def _tool_call_name(tc: Any) -> str:
|
||||
if isinstance(tc, dict):
|
||||
fn = tc.get("function")
|
||||
if isinstance(fn, dict):
|
||||
return str(fn.get("name") or "")
|
||||
return str(tc.get("name") or "")
|
||||
fn = getattr(tc, "function", None)
|
||||
if fn is not None:
|
||||
return str(getattr(fn, "name", "") or "")
|
||||
return str(getattr(tc, "name", "") or "")
|
||||
|
||||
|
||||
def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool:
|
||||
"""True if this conversation already invoked a terminal kanban tool."""
|
||||
if not messages:
|
||||
return False
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS:
|
||||
return True
|
||||
elif role == "tool":
|
||||
name = str(msg.get("name") or "")
|
||||
if name in _TERMINAL_KANBAN_TOOLS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_kanban_stop_nudge(
|
||||
*,
|
||||
messages: Iterable[dict] | None = None,
|
||||
attempts: int = 0,
|
||||
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
|
||||
task_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a synthetic follow-up when a kanban worker exits without a terminal tool.
|
||||
|
||||
Returns ``None`` when the guard should not fire (not a kanban worker,
|
||||
already completed/blocked, or nudge budget exhausted).
|
||||
"""
|
||||
if not kanban_stop_nudge_enabled():
|
||||
return None
|
||||
if attempts >= max_attempts:
|
||||
return None
|
||||
if session_called_kanban_terminal(messages):
|
||||
return None
|
||||
|
||||
tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task"
|
||||
return (
|
||||
"[System: You are a Hermes kanban worker. A plain-text reply is NOT a "
|
||||
"terminal state for the board.\n\n"
|
||||
f"Task `{tid}` is still `running`. Ending now without a board tool "
|
||||
"causes a protocol violation (clean exit with no "
|
||||
"`kanban_complete` / `kanban_block`).\n\n"
|
||||
"Do this immediately in your next response — do not narrate intent:\n"
|
||||
"1. Finish any remaining deliverable (write the required file(s) now).\n"
|
||||
"2. Call `kanban_complete(summary=..., artifacts=[...])` if the work "
|
||||
"is done, OR `kanban_block(reason=...)` if you are blocked.\n\n"
|
||||
"Never end a turn with only a promise of future action. Repeated "
|
||||
"protocol violations will block this task and require manual intervention.]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_kanban_stop_nudge",
|
||||
"kanban_stop_nudge_enabled",
|
||||
"session_called_kanban_terminal",
|
||||
]
|
||||
+116
-13
@@ -14,6 +14,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.message_content import flatten_message_text
|
||||
from agent.transports import get_transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -119,11 +120,54 @@ _REFERENCE_SYSTEM_PROMPT = (
|
||||
|
||||
|
||||
|
||||
def _slot_label(slot: dict[str, str]) -> str:
|
||||
return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
|
||||
def _slot_label(slot: dict[str, Any]) -> str:
|
||||
label = f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
|
||||
effort = str(slot.get("reasoning_effort") or "").strip()
|
||||
return f"{label}[reasoning={effort}]" if effort else label
|
||||
|
||||
|
||||
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
|
||||
def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Translate optional per-MoA-slot reasoning_effort into runtime config."""
|
||||
effort = slot.get("reasoning_effort")
|
||||
try:
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
|
||||
return parse_reasoning_effort(effort)
|
||||
except Exception: # pragma: no cover - defensive; bad config must not break MoA
|
||||
return None
|
||||
|
||||
|
||||
def _aggregator_reasoning_config(aggregator: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Resolve the aggregator's reasoning config: slot > per-model > global.
|
||||
|
||||
The aggregator is MoA's ACTING model, so when its slot doesn't pin a
|
||||
reasoning_effort it must resolve exactly like any other acting model:
|
||||
through the shared chokepoint (``resolve_reasoning_config``), which
|
||||
applies ``agent.reasoning_overrides`` for the slot's model first, then
|
||||
the global ``agent.reasoning_effort``. Without this the main loop's
|
||||
reasoning gates (keyed to the virtual ``moa://local`` identity) never
|
||||
fire, so the aggregator silently ran at the backend default (#64187).
|
||||
|
||||
Reference advisors intentionally do NOT get this fallback: they are side
|
||||
calls (like auxiliary tasks), and inheriting a global ``xhigh`` into every
|
||||
advisor fan-out would silently multiply cost. Their depth is slot-or-
|
||||
provider-default only.
|
||||
"""
|
||||
cfg = _slot_reasoning_config(aggregator)
|
||||
if cfg is not None:
|
||||
return cfg
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_constants import resolve_reasoning_config
|
||||
|
||||
return resolve_reasoning_config(
|
||||
load_config() or {}, str(aggregator.get("model") or "")
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive; bad config must not break MoA
|
||||
return None
|
||||
|
||||
|
||||
def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve a reference/aggregator slot to real runtime call kwargs.
|
||||
|
||||
A MoA slot is just a model selection — it must be called the same way any
|
||||
@@ -275,6 +319,7 @@ def _run_reference(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=_slot_reasoning_config(slot),
|
||||
**runtime,
|
||||
)
|
||||
usage = CanonicalUsage()
|
||||
@@ -470,13 +515,52 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
# Flatten structured content (lists of parts) to visible text. Content
|
||||
# arrives as a list — not a string — in two common cases:
|
||||
# 1. Anthropic prompt-cache decoration: conversation_loop runs
|
||||
# apply_anthropic_cache_control BEFORE the MoA facade, converting
|
||||
# string content to [{"type": "text", "text": ..., "cache_control":
|
||||
# ...}]. A str-only read here flattened the user's ENTIRE prompt to
|
||||
# "" — Claude references then 400'd ("messages: at least one
|
||||
# message is required") while tolerant models answered "no user
|
||||
# request is present".
|
||||
# 2. Multimodal turns (pasted image → text + image_url parts) and
|
||||
# multimodal tool results (screenshots).
|
||||
# flatten_message_text extracts the text parts and skips image parts,
|
||||
# and returns strings unchanged — so a decorated and an undecorated
|
||||
# transcript produce a byte-identical advisory view (which keeps the
|
||||
# advisory prefix stable across iterations for advisor prompt caching).
|
||||
text = flatten_message_text(content)
|
||||
|
||||
if role == "system":
|
||||
continue
|
||||
if role == "user":
|
||||
if text.strip():
|
||||
last_user_content = text
|
||||
if not text.strip() and isinstance(content, list) and content:
|
||||
# Structured content with no extractable text (e.g. an
|
||||
# image-only turn). Emitting an empty user message would be
|
||||
# dropped/rejected by strict providers (Anthropic 400s on
|
||||
# empty text blocks — the original "closed" preset failure
|
||||
# mode), and silently skipping the turn would break
|
||||
# user/assistant alternation in the advisory view. Substitute
|
||||
# a placeholder so the reference knows a non-text turn
|
||||
# happened. Only structured content qualifies — an empty or
|
||||
# whitespace-only STRING turn carries nothing and is dropped
|
||||
# below instead.
|
||||
text = "[user sent non-text content (e.g. an image attachment)]"
|
||||
if not text.strip():
|
||||
# Genuinely empty user turn (content="" / None). It carries
|
||||
# nothing advisory, and strict providers (Kimi/Moonshot, ZAI,
|
||||
# and others that enforce non-empty user content) reject it
|
||||
# with 400 "message ... with role 'user' must not be empty" —
|
||||
# the same way the assistant branch below drops turns with no
|
||||
# parts. Lenient providers (DeepSeek) accept the empty turn,
|
||||
# which is why a MoA fan-out would fail on one reference and
|
||||
# pass on another for the identical rendered view. The
|
||||
# advisory view is already not strictly alternating (adjacent
|
||||
# assistant turns occur in every tool loop), so dropping a
|
||||
# contentless turn is safe.
|
||||
continue
|
||||
last_user_content = text
|
||||
rendered.append({"role": "user", "content": text})
|
||||
elif role == "assistant":
|
||||
parts: list[str] = []
|
||||
@@ -517,8 +601,10 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if last_user_content is not None:
|
||||
return [{"role": "user", "content": last_user_content}]
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
return [{"role": "user", "content": msg["content"]}]
|
||||
if msg.get("role") == "user":
|
||||
fallback_text = flatten_message_text(msg.get("content"))
|
||||
if fallback_text.strip():
|
||||
return [{"role": "user", "content": fallback_text}]
|
||||
return rendered
|
||||
|
||||
|
||||
@@ -638,6 +724,7 @@ def aggregate_moa_context(
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator),
|
||||
**agg_runtime,
|
||||
)
|
||||
synthesis = _extract_text(response)
|
||||
@@ -671,13 +758,28 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
|
||||
Appending at the very end keeps the ``[system][task][tool-history]`` prefix
|
||||
stable and cache-reusable (only the new block re-prefills), and gives the
|
||||
aggregator the references with recency. Merge into the last message only when
|
||||
it is already a trailing string ``user`` turn (plain chat — still at the end).
|
||||
it is already a trailing ``user`` turn (plain chat — still at the end).
|
||||
|
||||
A trailing user turn's content may be a STRING or a LIST of content parts —
|
||||
Anthropic prompt-cache decoration (which runs before the MoA facade)
|
||||
converts string content to ``[{"type": "text", ..., "cache_control": ...}]``,
|
||||
and multimodal turns are lists natively. Both shapes are merged in place:
|
||||
appending a new text part AFTER the cache_control-marked part keeps the
|
||||
cached prefix byte-stable (the marker still terminates it) while the
|
||||
turn-varying guidance rides outside the cached span. Appending a SEPARATE
|
||||
user message here instead would produce two consecutive user turns —
|
||||
strict providers reject that.
|
||||
"""
|
||||
last = agg_messages[-1] if agg_messages else None
|
||||
if last is not None and last.get("role") == "user" and isinstance(last.get("content"), str):
|
||||
last["content"] = last["content"] + "\n\n" + guidance
|
||||
else:
|
||||
agg_messages.append({"role": "user", "content": guidance})
|
||||
if last is not None and last.get("role") == "user":
|
||||
last_content = last.get("content")
|
||||
if isinstance(last_content, str):
|
||||
last["content"] = last_content + "\n\n" + guidance
|
||||
return
|
||||
if isinstance(last_content, list):
|
||||
last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}]
|
||||
return
|
||||
agg_messages.append({"role": "user", "content": guidance})
|
||||
|
||||
|
||||
class MoAChatCompletions:
|
||||
@@ -1017,6 +1119,7 @@ class MoAChatCompletions:
|
||||
max_tokens=agg_kwargs.get("max_tokens"),
|
||||
tools=agg_kwargs.get("tools"),
|
||||
extra_body=agg_kwargs.get("extra_body"),
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator),
|
||||
**stream_kwargs,
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
|
||||
+30
-2
@@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str:
|
||||
# are preserved so the full model name reaches cache lookups and server queries.
|
||||
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
|
||||
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
@@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
|
||||
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
|
||||
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
|
||||
"ollama",
|
||||
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"mimo", "xiaomi-mimo",
|
||||
@@ -318,6 +318,16 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
|
||||
# Kimi
|
||||
"kimi": 262144,
|
||||
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
|
||||
# so these fallbacks keep token budgeting / compression from probing down
|
||||
# to the 128k default. Ids are matched longest-first, so dated variants
|
||||
# (e.g. solar-pro3-250127) resolve via their family prefix.
|
||||
# Sources: Solar Pro 3 = 128K, Solar Pro 2 = 64K, Solar Mini = 32K,
|
||||
# Solar Open 2 = 256K.
|
||||
"solar-open2": 262144, # 256K
|
||||
"solar-pro3": 131072,
|
||||
"solar-pro2": 65536,
|
||||
"solar-mini": 32768,
|
||||
# Tencent — Hy3 Preview (Hunyuan) with 256K context window.
|
||||
# OpenRouter live metadata reports 262144 (256 × 1024); align the
|
||||
# static fallback so cache and offline both agree (issue #22268).
|
||||
@@ -807,6 +817,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000)
|
||||
return pricing
|
||||
|
||||
# DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values:
|
||||
# ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to
|
||||
# per-token strings so the generic cost machinery (usage_pricing.py)
|
||||
# consumes them through the same path as OpenRouter / OpenAI.
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None
|
||||
deepinfra_pricing = metadata.get("pricing") if metadata else None
|
||||
if isinstance(deepinfra_pricing, dict) and any(
|
||||
k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens")
|
||||
):
|
||||
result: Dict[str, Any] = {}
|
||||
if deepinfra_pricing.get("input_tokens") is not None:
|
||||
result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000)
|
||||
if deepinfra_pricing.get("output_tokens") is not None:
|
||||
result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000)
|
||||
if deepinfra_pricing.get("cache_read_tokens") is not None:
|
||||
result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000)
|
||||
return result
|
||||
|
||||
alias_map = {
|
||||
"prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
|
||||
"completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
|
||||
|
||||
+22
-13
@@ -659,19 +659,7 @@ PLATFORM_HINTS = {
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
"Prefer bullet lists and labeled key:value pairs for structured data. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
@@ -865,6 +853,27 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
}
|
||||
|
||||
# Telegram rich-messages extension — only injected when the user has opted in
|
||||
# to ``platforms.telegram.extra.rich_messages: true``. The base
|
||||
# PLATFORM_HINTS["telegram"] covers MarkdownV2-compatible constructs; this
|
||||
# extension adds the Bot API 10.1 rich-Markdown guidance (tables, task lists,
|
||||
# collapsible details, math, etc.).
|
||||
TELEGRAM_RICH_MESSAGES_HINT = (
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment hints — execution-environment awareness for the agent.
|
||||
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
|
||||
|
||||
+27
-1
@@ -329,6 +329,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
|
||||
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
|
||||
from hermes_cli.commands import resolve_command
|
||||
disabled = _get_disabled_skill_names()
|
||||
seen_names: set = set()
|
||||
|
||||
@@ -374,7 +375,32 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')
|
||||
if not cmd_name:
|
||||
continue
|
||||
_skill_commands[f"/{cmd_name}"] = {
|
||||
# Skip if this skill's auto-generated /command collides
|
||||
# with a core Hermes slash command (name or alias). The
|
||||
# skill remains fully loadable via /skill <name>.
|
||||
# Uses resolve_command() so aliases and case variants are
|
||||
# covered without maintaining a separate cache.
|
||||
if resolve_command(cmd_name) is not None:
|
||||
logger.warning(
|
||||
"Skill %r generates slash command '/%s' which "
|
||||
"collides with a core Hermes command; skipping "
|
||||
"auto-registration. Use '/skill %s' instead.",
|
||||
name, cmd_name, name,
|
||||
)
|
||||
continue
|
||||
# Dedup on the resolved slug, not just the raw name: two
|
||||
# distinct frontmatter names can normalize to the same
|
||||
# slug (e.g. "git_helper" vs "git-helper"). First-wins
|
||||
# preserves local-before-external precedence.
|
||||
cmd_key = f"/{cmd_name}"
|
||||
if cmd_key in _skill_commands:
|
||||
logger.warning(
|
||||
"Skill %r maps to slash command %s already claimed "
|
||||
"by %r; keeping the first and skipping this one.",
|
||||
name, cmd_key, _skill_commands[cmd_key]["name"],
|
||||
)
|
||||
continue
|
||||
_skill_commands[cmd_key] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
"skill_md_path": str(skill_md),
|
||||
|
||||
@@ -40,6 +40,7 @@ from agent.prompt_builder import (
|
||||
SKILLS_GUIDANCE,
|
||||
STEER_CHANNEL_NOTE,
|
||||
TASK_COMPLETION_GUIDANCE,
|
||||
TELEGRAM_RICH_MESSAGES_HINT,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
drain_truncation_warnings,
|
||||
@@ -429,6 +430,20 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# For Telegram: append the rich-messages extension only when the user has
|
||||
# opted in to ``platforms.telegram.extra.rich_messages: true``. The base
|
||||
# hint covers MarkdownV2-compatible constructs; the extension adds Bot API
|
||||
# 10.1 guidance (tables, task lists, math, collapsible details, etc.).
|
||||
if platform_key == "telegram" and _default_hint:
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
_cfg = load_config_readonly()
|
||||
_tg_extra = ((_cfg.get("platforms") or {}).get("telegram") or {}).get("extra") or {}
|
||||
if _tg_extra.get("rich_messages"):
|
||||
_default_hint = _default_hint.rstrip() + " " + TELEGRAM_RICH_MESSAGES_HINT
|
||||
except Exception:
|
||||
pass # Config read failure — fall back to base hint only
|
||||
|
||||
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
|
||||
if platform_key == "tui" and _effective_hint:
|
||||
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
|
||||
|
||||
@@ -102,50 +102,119 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _should_parallelize_tool_batch(tool_calls) -> bool:
|
||||
"""Return True when a tool-call batch is safe to run concurrently."""
|
||||
if len(tool_calls) <= 1:
|
||||
return False
|
||||
def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
|
||||
"""Split a tool-call batch into ordered ``(kind, calls)`` segments.
|
||||
|
||||
tool_names = [tc.function.name for tc in tool_calls]
|
||||
if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names):
|
||||
return False
|
||||
``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe
|
||||
calls) or ``"sequential"`` (one or more barrier calls that must run
|
||||
in-order on the sequential path). Segments preserve the model's
|
||||
original call order exactly — a later call never crosses an earlier
|
||||
barrier — so tool-result ordering and side-effect boundaries are
|
||||
identical to fully-sequential execution. The per-call safety rules
|
||||
are the same ones the old all-or-nothing gate applied to the whole
|
||||
batch:
|
||||
|
||||
* ``_NEVER_PARALLEL_TOOLS`` (interactive tools) → barrier.
|
||||
* Unparseable / non-dict arguments → barrier.
|
||||
* Path-scoped tools (``read_file``/``write_file``/``patch``) join a
|
||||
parallel run only when their target path does not overlap another
|
||||
path already reserved in the same run; an overlap closes the run so
|
||||
the conflicting call starts a NEW run after the first completes.
|
||||
* Anything not in ``_PARALLEL_SAFE_TOOLS`` and not an opted-in MCP
|
||||
tool → barrier.
|
||||
|
||||
Parallel runs shorter than two calls are demoted to sequential (no
|
||||
concurrency win, and the sequential executor owns the richer inline
|
||||
dispatch), and adjacent sequential segments are merged.
|
||||
"""
|
||||
segments: list[list] = [] # [kind, calls] pairs, normalized to tuples on return
|
||||
current: list = []
|
||||
reserved_paths: list[Path] = []
|
||||
|
||||
def _close_parallel() -> None:
|
||||
nonlocal current, reserved_paths
|
||||
if current:
|
||||
segments.append(["parallel", current])
|
||||
current = []
|
||||
reserved_paths = []
|
||||
|
||||
def _add_sequential(tc) -> None:
|
||||
_close_parallel()
|
||||
if segments and segments[-1][0] == "sequential":
|
||||
segments[-1][1].append(tc)
|
||||
else:
|
||||
segments.append(["sequential", [tc]])
|
||||
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call.function.name
|
||||
|
||||
if tool_name in _NEVER_PARALLEL_TOOLS:
|
||||
_add_sequential(tool_call)
|
||||
continue
|
||||
|
||||
try:
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
except Exception:
|
||||
_raw = tool_call.function.arguments
|
||||
logging.debug(
|
||||
"Could not parse args for %s — defaulting to sequential; raw=%s",
|
||||
"Could not parse args for %s — treating as sequential barrier; raw=%s",
|
||||
tool_name,
|
||||
tool_call.function.arguments[:200],
|
||||
_raw[:200] if isinstance(_raw, str) else repr(_raw)[:200],
|
||||
)
|
||||
return False
|
||||
_add_sequential(tool_call)
|
||||
continue
|
||||
if not isinstance(function_args, dict):
|
||||
logging.debug(
|
||||
"Non-dict args for %s (%s) — defaulting to sequential",
|
||||
"Non-dict args for %s (%s) — treating as sequential barrier",
|
||||
tool_name,
|
||||
type(function_args).__name__,
|
||||
)
|
||||
return False
|
||||
_add_sequential(tool_call)
|
||||
continue
|
||||
|
||||
if tool_name in _PATH_SCOPED_TOOLS:
|
||||
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
|
||||
if scoped_path is None:
|
||||
return False
|
||||
_add_sequential(tool_call)
|
||||
continue
|
||||
if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths):
|
||||
return False
|
||||
# Same-subtree conflict inside this run: close it so this
|
||||
# call starts a fresh run AFTER the conflicting one lands.
|
||||
_close_parallel()
|
||||
reserved_paths.append(scoped_path)
|
||||
current.append(tool_call)
|
||||
continue
|
||||
|
||||
if tool_name not in _PARALLEL_SAFE_TOOLS:
|
||||
# Check if it's an MCP tool from a server that opted into parallel calls.
|
||||
if not _is_mcp_tool_parallel_safe(tool_name):
|
||||
return False
|
||||
if tool_name in _PARALLEL_SAFE_TOOLS or _is_mcp_tool_parallel_safe(tool_name):
|
||||
current.append(tool_call)
|
||||
continue
|
||||
|
||||
return True
|
||||
_add_sequential(tool_call)
|
||||
|
||||
_close_parallel()
|
||||
|
||||
normalized: list[list] = []
|
||||
for kind, calls in segments:
|
||||
if kind == "parallel" and len(calls) < 2:
|
||||
kind = "sequential"
|
||||
if normalized and normalized[-1][0] == "sequential" and kind == "sequential":
|
||||
normalized[-1][1].extend(calls)
|
||||
else:
|
||||
normalized.append([kind, calls])
|
||||
return [(kind, calls) for kind, calls in normalized]
|
||||
|
||||
|
||||
def _should_parallelize_tool_batch(tool_calls) -> bool:
|
||||
"""Return True when the WHOLE tool-call batch is safe to run concurrently.
|
||||
|
||||
Thin view over ``_plan_tool_batch_segments`` kept for callers/tests that
|
||||
only care about the homogeneous case: True iff the planner produces a
|
||||
single all-parallel segment.
|
||||
"""
|
||||
if len(tool_calls) <= 1:
|
||||
return False
|
||||
segments = _plan_tool_batch_segments(tool_calls)
|
||||
return len(segments) == 1 and segments[0][0] == "parallel"
|
||||
|
||||
|
||||
def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]:
|
||||
@@ -542,6 +611,7 @@ __all__ = [
|
||||
"_DESTRUCTIVE_PATTERNS",
|
||||
"_REDIRECT_OVERWRITE",
|
||||
"_is_destructive_command",
|
||||
"_plan_tool_batch_segments",
|
||||
"_should_parallelize_tool_batch",
|
||||
"_extract_parallel_scope_path",
|
||||
"_paths_overlap",
|
||||
|
||||
+71
-7
@@ -36,6 +36,7 @@ from agent.tool_dispatch_helpers import (
|
||||
_is_multimodal_tool_result,
|
||||
_multimodal_text_summary,
|
||||
_append_subdir_hint_to_multimodal,
|
||||
_plan_tool_batch_segments,
|
||||
make_tool_result_message,
|
||||
)
|
||||
from tools.terminal_tool import (
|
||||
@@ -322,11 +323,15 @@ def _run_agent_tool_execution_middleware(
|
||||
return result, observed_args
|
||||
|
||||
|
||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None:
|
||||
"""Execute multiple tool calls concurrently using a thread pool.
|
||||
|
||||
Results are collected in the original tool-call order and appended to
|
||||
messages so the API sees them in the expected sequence.
|
||||
|
||||
``finalize=False`` skips the end-of-batch aggregate budget enforcement
|
||||
and /steer injection — used when this call is one segment of a larger
|
||||
mixed batch and the segmented dispatcher owns the turn-end work.
|
||||
"""
|
||||
tool_calls = assistant_message.tool_calls
|
||||
num_tools = len(tool_calls)
|
||||
@@ -1006,7 +1011,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
|
||||
# ── Per-turn aggregate budget enforcement ─────────────────────────
|
||||
num_tools = len(parsed_calls)
|
||||
if num_tools > 0:
|
||||
if finalize and num_tools > 0:
|
||||
turn_tool_msgs = messages[-num_tools:]
|
||||
enforce_turn_budget(turn_tool_msgs, env=get_active_env(effective_task_id), config=_tool_budget)
|
||||
|
||||
@@ -1014,13 +1019,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# Append any pending user steer text to the last tool result so the
|
||||
# agent sees it on its next iteration. Runs AFTER budget enforcement
|
||||
# so the steer marker is never truncated. See steer() for details.
|
||||
if num_tools > 0:
|
||||
if finalize and num_tools > 0:
|
||||
agent._apply_pending_steer_to_tool_results(messages, num_tools)
|
||||
|
||||
|
||||
|
||||
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools."""
|
||||
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None:
|
||||
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools.
|
||||
|
||||
``finalize=False`` skips the end-of-batch aggregate budget enforcement
|
||||
and /steer injection — used when this call is one segment of a larger
|
||||
mixed batch and the segmented dispatcher owns the turn-end work.
|
||||
"""
|
||||
# Resolve the context-scaled tool-output budget once per turn.
|
||||
_tool_budget = _budget_for_agent(agent)
|
||||
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
|
||||
@@ -1716,19 +1726,73 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
|
||||
# ── Per-turn aggregate budget enforcement ─────────────────────────
|
||||
num_tools_seq = len(assistant_message.tool_calls)
|
||||
if num_tools_seq > 0:
|
||||
if finalize and num_tools_seq > 0:
|
||||
enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id), config=_tool_budget)
|
||||
|
||||
# ── /steer injection ──────────────────────────────────────────────
|
||||
# See _execute_tool_calls_parallel for the rationale. Same hook,
|
||||
# applied to sequential execution as well.
|
||||
if num_tools_seq > 0:
|
||||
if finalize and num_tools_seq > 0:
|
||||
agent._apply_pending_steer_to_tool_results(messages, num_tools_seq)
|
||||
|
||||
|
||||
|
||||
|
||||
def execute_tool_calls_segmented(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, segments=None) -> None:
|
||||
"""Execute a mixed tool-call batch as ordered parallel/sequential segments.
|
||||
|
||||
``segments`` is the ``(kind, calls)`` plan from
|
||||
``_plan_tool_batch_segments``: maximal contiguous runs of parallel-safe
|
||||
calls execute on the concurrent path, barrier calls on the sequential
|
||||
path, strictly in the model's original call order. Because segments are
|
||||
contiguous, every tool result is still appended one-per-call in emission
|
||||
order and no call ever starts before an earlier barrier finishes —
|
||||
identical ordering and side-effect boundaries to fully-sequential
|
||||
execution, with I/O parallelism recovered inside the safe runs.
|
||||
|
||||
Turn-end work (aggregate budget enforcement + /steer injection) is done
|
||||
once here for the WHOLE batch; the per-segment executor calls run with
|
||||
``finalize=False`` so a multi-segment turn cannot multiply the budget or
|
||||
truncate a steer marker.
|
||||
|
||||
Interrupt semantics: each segment executor already checks
|
||||
``agent._interrupt_requested`` up front and appends a cancelled/skipped
|
||||
result per call, so an interrupt during segment *k* drains segments
|
||||
*k+1..n* without executing them while preserving one result per
|
||||
tool_call_id.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
if segments is None:
|
||||
segments = _plan_tool_batch_segments(assistant_message.tool_calls)
|
||||
|
||||
for kind, calls in segments:
|
||||
segment_message = SimpleNamespace(tool_calls=list(calls))
|
||||
if kind == "parallel":
|
||||
execute_tool_calls_concurrent(
|
||||
agent, segment_message, messages, effective_task_id, api_call_count,
|
||||
finalize=False,
|
||||
)
|
||||
else:
|
||||
execute_tool_calls_sequential(
|
||||
agent, segment_message, messages, effective_task_id, api_call_count,
|
||||
finalize=False,
|
||||
)
|
||||
|
||||
# ── Whole-turn finalize (budget + /steer) ─────────────────────────
|
||||
total_tools = len(assistant_message.tool_calls)
|
||||
if total_tools > 0:
|
||||
_tool_budget = _budget_for_agent(agent)
|
||||
enforce_turn_budget(
|
||||
messages[-total_tools:],
|
||||
env=get_active_env(effective_task_id),
|
||||
config=_tool_budget,
|
||||
)
|
||||
agent._apply_pending_steer_to_tool_results(messages, total_tools)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"execute_tool_calls_concurrent",
|
||||
"execute_tool_calls_sequential",
|
||||
"execute_tool_calls_segmented",
|
||||
]
|
||||
|
||||
@@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
|
||||
"openai",
|
||||
"mistral",
|
||||
"xai",
|
||||
"elevenlabs",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ Spawned by: CodexAppServerSession.ensure_started() when the runtime is
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -52,6 +53,49 @@ from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JSON Schema type -> Python type mapping for signature generation
|
||||
_JSON_TO_PY = {
|
||||
"string": str,
|
||||
"integer": int,
|
||||
"number": float,
|
||||
"boolean": bool,
|
||||
"array": list,
|
||||
"object": dict,
|
||||
}
|
||||
|
||||
|
||||
def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict[str, type]]:
|
||||
"""Build a Python function signature and annotations from a JSON schema.
|
||||
|
||||
Args:
|
||||
schema: JSON Schema dict with "properties" and "required" keys.
|
||||
|
||||
Returns:
|
||||
(signature, annotations_dict) where signature has KEYWORD_ONLY params
|
||||
and annotations maps param names to Python types.
|
||||
"""
|
||||
props = (schema or {}).get("properties") or {}
|
||||
required = set((schema or {}).get("required") or [])
|
||||
params, annots = [], {}
|
||||
|
||||
for pname, pspec in props.items():
|
||||
if pname.startswith("_"):
|
||||
continue
|
||||
py = _JSON_TO_PY.get((pspec or {}).get("type"), Any)
|
||||
ann, default = (
|
||||
(py, inspect.Parameter.empty)
|
||||
if pname in required
|
||||
else (Optional[py], None)
|
||||
)
|
||||
annots[pname] = ann
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
pname, inspect.Parameter.KEYWORD_ONLY, annotation=ann, default=default
|
||||
)
|
||||
)
|
||||
|
||||
return inspect.Signature(params, return_annotation=str), annots
|
||||
|
||||
|
||||
# Tools we expose. Each name MUST match a registered Hermes tool that
|
||||
# `model_tools.handle_function_call()` can dispatch.
|
||||
@@ -159,29 +203,36 @@ def _build_server() -> Any:
|
||||
# the result string. We use add_tool() for full control over the
|
||||
# input schema (FastMCP's @tool() decorator inspects type hints,
|
||||
# which we can't get from a JSON schema at runtime).
|
||||
def _make_handler(tool_name: str):
|
||||
def _make_handler(tool_name: str, schema: dict | None):
|
||||
sig, annots = _signature_from_schema(schema)
|
||||
|
||||
def _dispatch(**kwargs: Any) -> str:
|
||||
try:
|
||||
return handle_function_call(tool_name, kwargs or {})
|
||||
# Filter out None values before dispatch so unset optionals
|
||||
# aren't forwarded to the handler.
|
||||
args = {k: v for k, v in kwargs.items() if v is not None}
|
||||
return handle_function_call(tool_name, args or {})
|
||||
except Exception as exc:
|
||||
logger.exception("tool %s raised", tool_name)
|
||||
return json.dumps({"error": str(exc), "tool": tool_name})
|
||||
|
||||
_dispatch.__name__ = tool_name
|
||||
_dispatch.__doc__ = description
|
||||
_dispatch.__signature__ = sig
|
||||
_dispatch.__annotations__ = {**annots, "return": str}
|
||||
return _dispatch
|
||||
|
||||
try:
|
||||
mcp.add_tool(
|
||||
_make_handler(name),
|
||||
_make_handler(name, params_schema),
|
||||
name=name,
|
||||
description=description,
|
||||
# FastMCP accepts JSON schema directly via the
|
||||
# input_schema parameter on newer versions; older
|
||||
# versions use parameters_schema. Try both for compat.
|
||||
)
|
||||
except TypeError:
|
||||
# Older mcp SDK signature — fall back to decorator-style.
|
||||
handler = _make_handler(name)
|
||||
# Older mcp SDK signature — fall back to decorator-style. The
|
||||
# synthesized __signature__ on the handler still drives schema
|
||||
# generation there.
|
||||
handler = _make_handler(name, params_schema)
|
||||
handler = mcp.tool(name=name, description=description)(handler)
|
||||
|
||||
exposed_count += 1
|
||||
|
||||
@@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({
|
||||
"neutts",
|
||||
"kittentts",
|
||||
"piper",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
||||
+50
-11
@@ -118,12 +118,12 @@ class TurnContext:
|
||||
|
||||
def build_turn_context(
|
||||
agent,
|
||||
user_message: str,
|
||||
user_message: Any,
|
||||
system_message: Optional[str],
|
||||
conversation_history: Optional[List[Dict[str, Any]]],
|
||||
task_id: Optional[str],
|
||||
stream_callback,
|
||||
persist_user_message: Optional[str],
|
||||
persist_user_message: Optional[Any],
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
*,
|
||||
restore_or_build_system_prompt,
|
||||
@@ -271,6 +271,29 @@ def build_turn_context(
|
||||
# Initialize conversation (copy to avoid mutating the caller's list).
|
||||
messages = list(conversation_history) if conversation_history else []
|
||||
|
||||
# The CLI may already have staged this input outside the history passed to
|
||||
# ``run_conversation``. Reuse it only when its clean transcript text matches
|
||||
# this turn; a stale handoff from a failed prior turn must not replace a
|
||||
# later, different user input. Voice turns compare against their explicit
|
||||
# clean persistence override rather than the API-only prefixed payload.
|
||||
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
|
||||
expected_persist_content = (
|
||||
persist_user_message if persist_user_message is not None else user_message
|
||||
)
|
||||
if (
|
||||
isinstance(pending_cli_message, dict)
|
||||
and pending_cli_message.get("content") == expected_persist_content
|
||||
):
|
||||
user_msg = pending_cli_message
|
||||
# The CLI-staged value is the clean transcript text. Restore the
|
||||
# API-facing variant (for example, a voice-mode prefix) while retaining
|
||||
# the same dict and any close-path durable marker.
|
||||
user_msg["content"] = user_message
|
||||
else:
|
||||
user_msg = {"role": "user", "content": user_message}
|
||||
if isinstance(pending_cli_message, dict):
|
||||
agent._pending_cli_user_message = None
|
||||
|
||||
# Hydrate todo store from conversation history.
|
||||
if conversation_history and not agent._todo_store.has_items():
|
||||
agent._hydrate_todo_store(conversation_history)
|
||||
@@ -285,6 +308,13 @@ def build_turn_context(
|
||||
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
|
||||
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
|
||||
|
||||
# Add the current user message after the prompt/session setup has made
|
||||
# close persistence safe. The handoff above preserves any marker already
|
||||
# stamped by an earlier close flush.
|
||||
messages.append(user_msg)
|
||||
current_turn_user_idx = len(messages) - 1
|
||||
agent._persist_user_message_idx = current_turn_user_idx
|
||||
|
||||
# Track user turns for memory flush and periodic nudge logic.
|
||||
agent._user_turn_count += 1
|
||||
# Copilot x-initiator: the first API call of this user turn is
|
||||
@@ -313,12 +343,6 @@ def build_turn_context(
|
||||
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
|
||||
|
||||
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
|
||||
# and notify the host so it can play hearts. Token-free, never touches the
|
||||
# conversation, and never fatal — a purely optional UI beat.
|
||||
@@ -348,18 +372,33 @@ def build_turn_context(
|
||||
|
||||
# Create the DB session row now that _cached_system_prompt is populated, so
|
||||
# the persisted snapshot is written non-NULL on the first turn (Issue
|
||||
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
|
||||
agent._ensure_db_session()
|
||||
# #45499). Keep row creation and the marker-based append in the same
|
||||
# per-agent critical section as CLI close persistence.
|
||||
persist_lock = getattr(agent, "_session_persist_lock", None)
|
||||
|
||||
def _ensure_and_persist() -> None:
|
||||
agent._ensure_db_session()
|
||||
agent._persist_session(messages, conversation_history)
|
||||
|
||||
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
|
||||
try:
|
||||
agent._persist_session(messages, conversation_history)
|
||||
if persist_lock is None:
|
||||
_ensure_and_persist()
|
||||
else:
|
||||
with persist_lock:
|
||||
_ensure_and_persist()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Early turn-start session persistence failed for session=%s",
|
||||
agent.session_id or "none",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
# Keep an unmarked staged input available to a later close retry if the
|
||||
# normal persistence attempt failed. Once the marker is present, the
|
||||
# close path must no longer treat it as a pre-worker UI input.
|
||||
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
|
||||
agent._pending_cli_user_message = None
|
||||
|
||||
# ── Preflight context compression ──
|
||||
# Gate the (expensive) full token estimate behind a cheap pre-check.
|
||||
|
||||
@@ -228,6 +228,15 @@ def finalize_turn(
|
||||
if _tail_role != "assistant":
|
||||
messages.append({"role": "assistant", "content": final_response})
|
||||
|
||||
# The model has completed its request, so replace API-local
|
||||
# voice/model/skill guidance with the clean user input before writing the
|
||||
# final durable snapshot and returning the continuation history. Earlier
|
||||
# turn-start flushes use the DB-only override because their messages are
|
||||
# still needed for the API request; this finalizer runs after that request
|
||||
# is complete (#48677 / #63766).
|
||||
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
|
||||
if callable(_apply_override):
|
||||
_apply_override(messages)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
except Exception as _persist_err:
|
||||
_cleanup_errors.append(f"persist_session: {_persist_err}")
|
||||
@@ -449,6 +458,11 @@ def finalize_turn(
|
||||
"estimated_cost_usd": agent.session_estimated_cost_usd,
|
||||
"cost_status": agent.session_cost_status,
|
||||
"cost_source": agent.session_cost_source,
|
||||
# Requested service tier (from request_overrides.extra_body), for
|
||||
# billing audits by callers like `hermes -z --usage-file`.
|
||||
"service_tier": (
|
||||
(getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {}
|
||||
).get("service_tier"),
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
if agent._tool_guardrail_halt_decision is not None:
|
||||
|
||||
@@ -244,6 +244,78 @@ def save_bytes_video(
|
||||
return path
|
||||
|
||||
|
||||
_URL_VIDEO_CONTENT_TYPES = {
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-matroska": "mkv",
|
||||
}
|
||||
|
||||
|
||||
def save_url_video(
|
||||
url: str,
|
||||
*,
|
||||
prefix: str = "video",
|
||||
timeout: float = 180.0,
|
||||
max_bytes: int = 200 * 1024 * 1024,
|
||||
) -> Path:
|
||||
"""Download a video URL and write it under ``$HERMES_HOME/cache/videos/``.
|
||||
|
||||
The video twin of :func:`agent.image_gen_provider.save_url_image`: several
|
||||
backends (DeepInfra, FAL) return an *ephemeral* delivery URL that expires
|
||||
before a downstream consumer can fetch it, so we materialise the bytes
|
||||
locally at tool-completion time. Streams with a size cap.
|
||||
|
||||
Raises on any network / HTTP / oversize error so callers can fall back to
|
||||
returning the bare URL.
|
||||
"""
|
||||
import requests
|
||||
|
||||
response = requests.get(url, timeout=timeout, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
extension = _URL_VIDEO_CONTENT_TYPES.get(content_type)
|
||||
if extension is None:
|
||||
url_path = url.split("?", 1)[0].lower()
|
||||
for ext in ("mp4", "webm", "mov", "mkv"):
|
||||
if url_path.endswith(f".{ext}"):
|
||||
extension = ext
|
||||
break
|
||||
if extension is None:
|
||||
extension = "mp4"
|
||||
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
short = uuid.uuid4().hex[:8]
|
||||
path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}"
|
||||
|
||||
bytes_written = 0
|
||||
with path.open("wb") as fh:
|
||||
for chunk in response.iter_content(chunk_size=256 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
bytes_written += len(chunk)
|
||||
if bytes_written > max_bytes:
|
||||
fh.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"Video at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
|
||||
)
|
||||
fh.write(chunk)
|
||||
|
||||
if bytes_written == 0:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise ValueError(f"Video at {url} was empty (0 bytes).")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def success_response(
|
||||
*,
|
||||
video: str,
|
||||
@@ -297,3 +369,222 @@ def error_response(
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reusable OpenAI-compatible backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
"""Generic text/image-to-video over the OpenAI ``client.videos`` API.
|
||||
|
||||
DeepInfra, OpenAI/Sora, and OpenRouter all expose the same
|
||||
``POST /videos`` async-job shape (``create`` → poll → ``download_content``),
|
||||
so the SDK call lives here once. A concrete backend only needs to declare
|
||||
its identity and credentials::
|
||||
|
||||
class FooVideoGenProvider(OpenAICompatibleVideoGenProvider):
|
||||
name = "foo"
|
||||
_env_key = "FOO_API_KEY"
|
||||
_default_base_url = "https://api.foo.com/v1/openai"
|
||||
def list_models(self):
|
||||
return [...] # entries with an "id" key; default_model() uses [0]
|
||||
|
||||
``image_url`` routes to image-to-video; its absence routes to text-to-video.
|
||||
Provider-specific fields (``image_url``/``negative_prompt``/``seed``) ride
|
||||
in ``extra_body`` so they pass through the SDK unchanged.
|
||||
"""
|
||||
|
||||
_env_key: str = "OPENAI_API_KEY"
|
||||
_default_base_url: str = "https://api.openai.com/v1"
|
||||
|
||||
# Polling cadence for the async video job. The OpenAI SDK's
|
||||
# ``create_and_poll`` defaults to ~1 poll/second and loops forever on a
|
||||
# non-terminal status, so a multi-minute job issues hundreds of sequential
|
||||
# requests and a stuck job pins its tool-executor worker thread with no way
|
||||
# out. We hand-roll a bounded poll instead: a coarse interval plus a hard
|
||||
# wall-clock deadline that surfaces a timeout error.
|
||||
_poll_interval_s: float = 5.0
|
||||
_poll_deadline_s: float = 900.0
|
||||
|
||||
def _api_key(self) -> str:
|
||||
import os
|
||||
|
||||
return os.environ.get(self._env_key, "").strip()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self._api_key())
|
||||
|
||||
def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any:
|
||||
"""Create the video job and poll to completion with a hard deadline.
|
||||
|
||||
Replaces ``client.videos.create_and_poll`` (unbounded 1/s loop) with a
|
||||
coarse interval and a wall-clock cap. Returns the terminal video object
|
||||
(any status); raises :class:`TimeoutError` if the deadline passes
|
||||
first.
|
||||
"""
|
||||
import time
|
||||
|
||||
video = client.videos.create(**call_kwargs)
|
||||
terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"}
|
||||
deadline = time.monotonic() + self._poll_deadline_s
|
||||
while getattr(video, "status", None) not in terminal:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"video job {getattr(video, 'id', '?')} did not reach a terminal "
|
||||
f"status within {int(self._poll_deadline_s)}s "
|
||||
f"(last status={getattr(video, 'status', None)!r})"
|
||||
)
|
||||
time.sleep(self._poll_interval_s)
|
||||
video = client.videos.retrieve(video.id)
|
||||
return video
|
||||
|
||||
def _base_url(self) -> str:
|
||||
import os
|
||||
|
||||
override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip()
|
||||
return override or self._default_base_url
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
duration: Optional[int] = None,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
resolution: str = DEFAULT_RESOLUTION,
|
||||
negative_prompt: Optional[str] = None,
|
||||
audio: Optional[bool] = None,
|
||||
seed: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if not prompt or not prompt.strip():
|
||||
return error_response(
|
||||
error="prompt is required", error_type="invalid_request", provider=self.name
|
||||
)
|
||||
if not self._api_key():
|
||||
return error_response(
|
||||
error=f"{self._env_key} is not set",
|
||||
error_type="missing_credentials",
|
||||
provider=self.name,
|
||||
)
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="openai Python package not installed (pip install openai)",
|
||||
error_type="missing_dependency",
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
model_id = model or self.default_model()
|
||||
if not model_id:
|
||||
return error_response(
|
||||
error=f"no {self.name} video model available (live catalog empty?)",
|
||||
error_type="no_model",
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
# Provider-specific fields the OpenAI ``videos.create`` signature does
|
||||
# not name natively — pass them through ``extra_body``.
|
||||
extra_body = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"negative_prompt": negative_prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"image_url": image_url, # presence ⇒ image-to-video
|
||||
"seed": seed,
|
||||
}.items()
|
||||
if v is not None
|
||||
}
|
||||
call_kwargs: Dict[str, Any] = {"model": model_id, "prompt": prompt}
|
||||
if duration:
|
||||
call_kwargs["seconds"] = str(duration)
|
||||
if resolution:
|
||||
call_kwargs["size"] = resolution
|
||||
if extra_body:
|
||||
call_kwargs["extra_body"] = extra_body
|
||||
|
||||
client = openai.OpenAI(api_key=self._api_key(), base_url=self._base_url())
|
||||
try:
|
||||
try:
|
||||
video = self._create_and_poll(client, call_kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly
|
||||
logger.debug("%s video generation failed", self.name, exc_info=True)
|
||||
return error_response(
|
||||
error=f"{self.name} video generation failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider=self.name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
# Terminal success status differs across backends: DeepInfra reports
|
||||
# "succeeded", OpenAI/Sora reports "completed". Accept both.
|
||||
status = getattr(video, "status", None)
|
||||
if status not in ("completed", "succeeded"):
|
||||
# ``video.error`` is a structured SDK object (pydantic
|
||||
# VideoCreateError), not a string — str() it so the response
|
||||
# dict stays JSON-serializable for the tool layer.
|
||||
job_error = getattr(video, "error", None)
|
||||
return error_response(
|
||||
error=str(job_error) if job_error else f"video job ended with status={status!r}",
|
||||
error_type="job_failed",
|
||||
provider=self.name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
# Resolve the output. Providers expose it either as a delivery URL in
|
||||
# the job's ``data`` list (DeepInfra, FAL-style) or only via the SDK
|
||||
# download endpoint (OpenAI/Sora). Download the bytes and save locally
|
||||
# so the caller gets a durable file — DeepInfra's delivery URLs in
|
||||
# particular are short-lived. Matches plugins/image_gen/deepinfra.
|
||||
url = None
|
||||
for item in getattr(video, "data", None) or []:
|
||||
candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
|
||||
if candidate:
|
||||
url = candidate
|
||||
break
|
||||
|
||||
try:
|
||||
if url:
|
||||
# Materialise the (often short-lived) delivery URL locally.
|
||||
video_ref = str(save_url_video(url, prefix=self.name))
|
||||
else:
|
||||
# OpenAI/Sora style: no public URL — pull bytes via the SDK.
|
||||
raw = client.videos.download_content(video.id).read()
|
||||
video_ref = str(save_bytes_video(raw, prefix=self.name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if url:
|
||||
# Best-effort: hand back the URL rather than fail outright.
|
||||
logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc)
|
||||
video_ref = url
|
||||
else:
|
||||
return error_response(
|
||||
error=f"{self.name} video job succeeded but no output could be retrieved: {exc}",
|
||||
error_type="empty_response",
|
||||
provider=self.name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
video=video_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
modality="image" if image_url else "text",
|
||||
aspect_ratio=aspect_ratio,
|
||||
duration=duration or 0,
|
||||
provider=self.name,
|
||||
)
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
@@ -11,12 +11,15 @@ Active selection
|
||||
The active provider is chosen by ``video_gen.provider`` in ``config.yaml``.
|
||||
If unset, :func:`get_active_provider` applies fallback logic:
|
||||
|
||||
1. If exactly one provider is registered, use it.
|
||||
1. If exactly one *available* provider is registered, use it.
|
||||
2. Otherwise return ``None`` (the tool surfaces a helpful error pointing
|
||||
the user at ``hermes tools``).
|
||||
|
||||
Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the
|
||||
same.
|
||||
same: the unconfigured fallback is filtered by ``is_available()`` so a box
|
||||
that has credentials for only one backend (e.g. DeepInfra, while the
|
||||
``fal``/``xai`` plugins also register unconditionally) auto-selects it
|
||||
instead of returning ``None``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -100,13 +103,26 @@ def get_active_provider() -> Optional[VideoGenProvider]:
|
||||
if provider is not None:
|
||||
return provider
|
||||
logger.debug(
|
||||
"video_gen.provider='%s' configured but not registered; falling back",
|
||||
"video_gen.provider='%s' configured but not registered; failing closed",
|
||||
configured,
|
||||
)
|
||||
return None
|
||||
|
||||
# Fallback: single-provider case
|
||||
if len(snapshot) == 1:
|
||||
return next(iter(snapshot.values()))
|
||||
def _is_available_safe(p: VideoGenProvider) -> bool:
|
||||
"""Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""
|
||||
try:
|
||||
return bool(p.is_available())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc)
|
||||
return False
|
||||
|
||||
# Fallback: single *available* provider — filter by is_available() so a
|
||||
# box with credentials for only one backend auto-selects it even when
|
||||
# other providers (fal/xai) register unconditionally without keys.
|
||||
# Mirrors agent/image_gen_registry.get_active_provider().
|
||||
available = [p for p in snapshot.values() if _is_available_safe(p)]
|
||||
if len(available) == 1:
|
||||
return available[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"check": "npm run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* backend-child.ts
|
||||
*
|
||||
* Windows-aware teardown for the desktop's managed backend child process.
|
||||
*
|
||||
* Node's `child.kill()` only signals the direct child. On Windows a backend
|
||||
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
|
||||
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
|
||||
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`;
|
||||
* everywhere else a plain SIGTERM is correct and sufficient (POSIX has no
|
||||
* mandatory locks, and the backend is not spawned detached so there's no
|
||||
* process-group to negative-pid-kill).
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so the
|
||||
* SIGTERM-vs-tree-kill branching can be asserted directly with a fake child
|
||||
* object and a spy `forceKillProcessTree`, instead of grepping main.ts source
|
||||
* text for the function body.
|
||||
*/
|
||||
|
||||
export interface StopBackendChildDeps {
|
||||
/** Defaults to the real platform check; injectable for tests. */
|
||||
isWindows?: boolean
|
||||
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
|
||||
forceKillProcessTree: (pid: number) => void
|
||||
}
|
||||
|
||||
export interface KillableChild {
|
||||
pid?: number | null
|
||||
killed?: boolean
|
||||
kill: (signal: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a managed child process, choosing the right strategy for the platform.
|
||||
* No-ops silently if `child` is falsy, already killed, or the kill attempt
|
||||
* throws (the process may already be gone) -- mirrors the original inline
|
||||
* best-effort semantics in main.ts.
|
||||
*/
|
||||
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
|
||||
const isWindows = deps.isWindows ?? process.platform === 'win32'
|
||||
|
||||
try {
|
||||
if (isWindows && Number.isInteger(child.pid)) {
|
||||
deps.forceKillProcessTree(child.pid as number)
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict'
|
||||
|
||||
// Backend subcommand routing for the desktop-managed Hermes process.
|
||||
//
|
||||
// The desktop app launches its own headless backend via `hermes serve` — it
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
appendUniquePathEntries,
|
||||
|
||||
@@ -44,12 +44,14 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
@@ -77,6 +79,7 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor
|
||||
if (!hermesHome) {
|
||||
return hermesHome
|
||||
}
|
||||
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
|
||||
@@ -60,6 +60,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
@@ -130,12 +131,14 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
@@ -171,6 +174,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
bundledRuntimeImportCheck,
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPinArgs,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.ts:
|
||||
* import { runBootstrap }from './bootstrap-runner.ts'
|
||||
* import { runBootstrap }from './bootstrap-runner'
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
@@ -38,16 +38,10 @@ import fsp from 'node:fs/promises'
|
||||
import https from 'node:https'
|
||||
import path from 'node:path'
|
||||
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
@@ -73,6 +67,7 @@ function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
@@ -96,6 +91,7 @@ function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
@@ -425,6 +421,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
@@ -441,6 +438,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed } as any)
|
||||
})
|
||||
})
|
||||
@@ -517,6 +515,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
@@ -532,6 +531,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
AT_COOKIE_VARIANTS,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
adoptServedDashboardToken,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
|
||||
@@ -106,6 +106,7 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) {
|
||||
if (env.APPIMAGE) {
|
||||
return env.APPIMAGE
|
||||
}
|
||||
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
|
||||
function mkTmpDir() {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function probeGatewayWebSocket<T>(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimers()
|
||||
|
||||
@@ -107,6 +108,7 @@ function probeGatewayWebSocket<T>(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
@@ -189,6 +191,7 @@ function extractErrorReason(event) {
|
||||
if (event instanceof Error) {
|
||||
return event.message
|
||||
}
|
||||
|
||||
const err = event.error || event.message
|
||||
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveRenamePath } from './git-review-ops'
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
function mkTmpDir() {
|
||||
@@ -19,20 +20,23 @@ test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
try {
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,11 +3,13 @@ import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
parseWorktrees,
|
||||
sanitizeBranch,
|
||||
@@ -210,3 +212,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: lists local branches and flags the default', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const trunk = git('branch', '--show-current')
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
const branches = await listBaseBranches(dir, 'git')
|
||||
const names = branches.map(b => b.name).sort()
|
||||
|
||||
assert.deepEqual(names, [trunk, 'feature'].sort())
|
||||
// No remote → all local.
|
||||
assert.equal(branches.every(b => !b.isRemote), true)
|
||||
// The trunk is flagged as the default.
|
||||
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: empty on a non-repo path', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-'))
|
||||
|
||||
try {
|
||||
assert.deepEqual(await listBaseBranches(dir, 'git'), [])
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: base param branches off a specified local branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'staging'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
|
||||
|
||||
assert.equal(result.branch, 'new-from-staging')
|
||||
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -251,7 +251,25 @@ async function addWorktree(repoPath, options, gitBin) {
|
||||
const args = ['worktree', 'add', '-b', branch, dir]
|
||||
|
||||
if (opts.base) {
|
||||
args.push(String(opts.base))
|
||||
// Remote-tracking branches may be stale or missing if the user hasn't
|
||||
// fetched recently. When the base is an `origin/…` ref, fetch just that
|
||||
// branch so `git worktree add -b new origin/main` works against the
|
||||
// latest remote commit. Local branches are used as-is.
|
||||
const base = String(opts.base)
|
||||
|
||||
if (base.startsWith('origin/')) {
|
||||
const remoteBranch = base.slice('origin/'.length)
|
||||
|
||||
try {
|
||||
await runGit(gitBin, ['fetch', 'origin', remoteBranch], root)
|
||||
} catch {
|
||||
// The fetch isn't mandatory, but it would be nice to do if possible.
|
||||
// If it's not possible, just use the local ref of the remote branch.
|
||||
// If it doesn't exist locally, we'll get an error anyways
|
||||
}
|
||||
}
|
||||
|
||||
args.push(base)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -337,9 +355,56 @@ async function switchBranch(repoPath, branch, gitBin) {
|
||||
return { branch: target }
|
||||
}
|
||||
|
||||
// Branches the new worktree can be based on: local heads + remote-tracking
|
||||
// refs. Listed most-recently-committed first; the remote's default branch
|
||||
// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo /
|
||||
// remote backend where the probe can't run.
|
||||
async function listBaseBranches(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
|
||||
resolved
|
||||
)
|
||||
|
||||
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
|
||||
const localDefault = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [name] = line.split('\t')
|
||||
|
||||
return {
|
||||
name,
|
||||
isRemote: name.startsWith('origin/'),
|
||||
// origin/HEAD when a remote exists; otherwise the local default
|
||||
// (main/master/init.defaultBranch) so a no-remote repo still flags
|
||||
// its trunk.
|
||||
isDefault: Boolean(
|
||||
(remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault)
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
parseWorktrees,
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
@@ -122,171 +123,184 @@ test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
try {
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
})
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
})
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
try {
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
return
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
// symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
return
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
|
||||
try {
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
// directory symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
||||
@@ -112,6 +112,7 @@ function sensitiveFileBlockReason(filePath) {
|
||||
|
||||
function ipcPathError(code: any, message: string): Error & { code: any } {
|
||||
const error = new Error(message) as Error & { code: any }
|
||||
|
||||
;(error as any).code = code
|
||||
|
||||
return error
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
createLinkTitleWindow,
|
||||
|
||||
@@ -55,6 +55,7 @@ export function readLinkTitleWindowTitle(window) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const contents = window.webContents
|
||||
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
|
||||
+131
-184
@@ -1,5 +1,4 @@
|
||||
type Method = string
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
|
||||
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from 'electron'
|
||||
import nodePty from 'node-pty'
|
||||
|
||||
import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
} from './hardening'
|
||||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
@@ -125,6 +126,8 @@ import {
|
||||
MIN_HEIGHT as WINDOW_MIN_HEIGHT,
|
||||
MIN_WIDTH as WINDOW_MIN_WIDTH
|
||||
} from './window-state'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
import { readWindowsUserEnvVar } from './windows-user-env'
|
||||
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
|
||||
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
|
||||
@@ -152,14 +155,6 @@ const APP_ROOT = app.getAppPath()
|
||||
// Dev (`npm run dev`) and prod both load the esbuild output from dist/.
|
||||
const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js')
|
||||
|
||||
function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options as any
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true } as any
|
||||
}
|
||||
|
||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||
@@ -870,6 +865,7 @@ function planDesktopLogRotation(size) {
|
||||
if (size < DESKTOP_LOG_MAX_BYTES) {
|
||||
return []
|
||||
}
|
||||
|
||||
const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1))
|
||||
|
||||
// Pathological boot-loop log: reclaim live + every backup outright.
|
||||
@@ -937,6 +933,7 @@ function flushDesktopLogBufferSync() {
|
||||
if (!desktopLogBuffer) {
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = desktopLogBuffer
|
||||
desktopLogBuffer = ''
|
||||
|
||||
@@ -953,6 +950,7 @@ function flushDesktopLogBufferAsync() {
|
||||
if (!desktopLogBuffer) {
|
||||
return desktopLogFlushPromise
|
||||
}
|
||||
|
||||
const chunk = desktopLogBuffer
|
||||
desktopLogBuffer = ''
|
||||
|
||||
@@ -973,6 +971,7 @@ function scheduleDesktopLogFlush() {
|
||||
if (desktopLogFlushTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
desktopLogFlushTimer = setTimeout(() => {
|
||||
desktopLogFlushTimer = null
|
||||
void flushDesktopLogBufferAsync()
|
||||
@@ -985,6 +984,7 @@ function rememberLog(chunk) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`)
|
||||
hermesLog.push(...lines)
|
||||
|
||||
@@ -1183,11 +1183,13 @@ function broadcastBootProgress() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:boot-progress', bootProgressState)
|
||||
}
|
||||
|
||||
@@ -1267,11 +1269,13 @@ function broadcastBootstrapEvent(ev) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:bootstrap:event', ev)
|
||||
}
|
||||
|
||||
@@ -1421,9 +1425,7 @@ function findOnPath(command) {
|
||||
// shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.
|
||||
// The empty entry is kept LAST so callers that already include the extension
|
||||
// (py.exe, pwsh.exe, powershell.exe) still resolve.
|
||||
const extensions = IS_WINDOWS
|
||||
? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
: ['']
|
||||
const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS)
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
for (const extension of extensions) {
|
||||
@@ -1443,71 +1445,21 @@ function isCommandScript(command) {
|
||||
}
|
||||
|
||||
function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
if (!IS_WINDOWS || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = path.resolve(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = path.dirname(resolved)
|
||||
|
||||
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = path.dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = path.dirname(venvRoot)
|
||||
|
||||
// Smoke-test the venv interpreter before trusting it. A venv whose update
|
||||
// died mid-`pip install` still has python.exe + hermes.exe on disk, but the
|
||||
// backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before
|
||||
// the gateway ever binds. Returning it here also BYPASSED the caller's
|
||||
// `--version` probe, so Retry/"Repair install" re-resolved the same broken
|
||||
// venv forever instead of falling through to the bootstrap installer.
|
||||
// Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a
|
||||
// healthy source-tree venv passes.
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
// Surfaced so backendSupportsServe() can read this runtime's source for the
|
||||
// `serve` capability check instead of falling back to a heavyweight probe.
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
return resolveVenvHermesCommand(command, backendArgs, {
|
||||
isWindows: IS_WINDOWS,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome: HERMES_HOME,
|
||||
resolvePath: (...segments) => path.resolve(...segments),
|
||||
dirname: p => path.dirname(p),
|
||||
basename: p => path.basename(p),
|
||||
rememberLog
|
||||
})
|
||||
}
|
||||
|
||||
// Does the resolved runtime understand the `serve` subcommand? The desktop
|
||||
@@ -1526,6 +1478,7 @@ function backendSupportsServe(backend) {
|
||||
if (!backend || !backend.command) {
|
||||
return true
|
||||
}
|
||||
|
||||
const key = `${backend.command}::${backend.root || ''}`
|
||||
|
||||
if (_serveSupportCache.has(key)) {
|
||||
@@ -1855,45 +1808,6 @@ function getVenvPython(venvRoot) {
|
||||
// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is
|
||||
// needed.
|
||||
|
||||
function getVenvSitePackagesEntries(venvRoot) {
|
||||
const entries = []
|
||||
|
||||
if (!venvRoot) {
|
||||
return entries
|
||||
}
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
const version = (() => {
|
||||
try {
|
||||
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
|
||||
|
||||
return match ? match[1].trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (version) {
|
||||
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function makeDashboardReadyFile() {
|
||||
const dir = path.join(app.getPath('userData'), 'backend-ready')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
@@ -2326,6 +2240,7 @@ function isShimLocked(shimPath) {
|
||||
if (!IS_WINDOWS) {
|
||||
return false
|
||||
}
|
||||
|
||||
let fd
|
||||
|
||||
try {
|
||||
@@ -2463,6 +2378,7 @@ async function releaseBackendLock(updateRoot, tag) {
|
||||
for (const pid of stragglers) {
|
||||
forceKillProcessTree(pid)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
@@ -2662,7 +2578,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch)
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
@@ -2757,6 +2673,7 @@ function runningAppBundle() {
|
||||
if (!IS_MAC) {
|
||||
return null
|
||||
}
|
||||
|
||||
let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -3183,6 +3100,7 @@ function resolveRendererIndex() {
|
||||
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.
|
||||
@@ -3231,6 +3149,7 @@ function resolveHermesCwd() {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = path.resolve(String(candidate))
|
||||
|
||||
if (isPackagedInstallPath(resolved)) {
|
||||
@@ -3755,6 +3674,7 @@ function fetchJson(url, token, options: any = {}) {
|
||||
if (body) {
|
||||
req.write(body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -3844,6 +3764,7 @@ function fetchPublicJson(url, options: any = {}) {
|
||||
if (body) {
|
||||
req.write(body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -3961,6 +3882,7 @@ function cacheTitle(key, title) {
|
||||
if (titleCache.size >= TITLE_CACHE_LIMIT) {
|
||||
titleCache.delete(titleCache.keys().next().value)
|
||||
}
|
||||
|
||||
titleCache.set(key, title)
|
||||
}
|
||||
|
||||
@@ -4015,6 +3937,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
|
||||
if (bytes >= TITLE_BYTE_BUDGET) {
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const remaining = TITLE_BYTE_BUDGET - bytes
|
||||
const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer
|
||||
@@ -4027,6 +3950,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
|
||||
if (!chunks.length) {
|
||||
return resolve('')
|
||||
}
|
||||
|
||||
resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8')))
|
||||
})
|
||||
})
|
||||
@@ -4036,6 +3960,7 @@ function getLinkTitleSession() {
|
||||
if (linkTitleSession || !app.isReady()) {
|
||||
return linkTitleSession
|
||||
}
|
||||
|
||||
linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false })
|
||||
linkTitleSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) })
|
||||
@@ -4078,6 +4003,7 @@ function runRenderTitleJob(rawUrl) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (hardTimer) {
|
||||
@@ -4087,6 +4013,7 @@ function runRenderTitleJob(rawUrl) {
|
||||
if (graceTimer) {
|
||||
clearTimeout(graceTimer)
|
||||
}
|
||||
|
||||
const value = (title || '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
try {
|
||||
@@ -4112,6 +4039,7 @@ function runRenderTitleJob(rawUrl) {
|
||||
if (graceTimer) {
|
||||
clearTimeout(graceTimer)
|
||||
}
|
||||
|
||||
graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS)
|
||||
}
|
||||
|
||||
@@ -4193,6 +4121,7 @@ async function resourceBufferFromUrl(rawUrl) {
|
||||
if (!match) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
|
||||
const mimeType = match[1] || 'application/octet-stream'
|
||||
const encoded = match[3] || ''
|
||||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||||
@@ -4241,6 +4170,7 @@ async function copyImageFromUrl(rawUrl) {
|
||||
if (image.isEmpty()) {
|
||||
throw new Error('Could not read image')
|
||||
}
|
||||
|
||||
clipboard.writeImage(image)
|
||||
}
|
||||
|
||||
@@ -4256,6 +4186,7 @@ async function saveImageFromUrl(rawUrl) {
|
||||
if (result.canceled || !result.filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(result.filePath, buffer)
|
||||
|
||||
return true
|
||||
@@ -4390,11 +4321,13 @@ function sendPreviewFileChanged(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
@@ -4415,12 +4348,14 @@ async function watchPreviewFile(rawUrl) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
|
||||
if (!fileExists(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
|
||||
}, PREVIEW_WATCH_DEBOUNCE_MS)
|
||||
})
|
||||
@@ -4430,6 +4365,7 @@ async function watchPreviewFile(rawUrl) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
watcher.close()
|
||||
}
|
||||
})
|
||||
@@ -4504,11 +4440,13 @@ function sendBackendExit(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:backend-exit', payload)
|
||||
}
|
||||
|
||||
@@ -4516,11 +4454,13 @@ function sendClosePreviewRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:close-preview-requested')
|
||||
}
|
||||
|
||||
@@ -4531,11 +4471,13 @@ function sendPowerResume() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:power-resume')
|
||||
}
|
||||
|
||||
@@ -4545,6 +4487,7 @@ function registerPowerResumeListeners() {
|
||||
if (powerResumeRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
powerResumeRegistered = true
|
||||
|
||||
try {
|
||||
@@ -4566,16 +4509,19 @@ function sendOpenUpdatesRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:open-updates')
|
||||
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
@@ -4583,11 +4529,13 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = getWindowState()
|
||||
|
||||
if (typeof nextIsFullscreen === 'boolean') {
|
||||
@@ -4732,6 +4680,7 @@ function installDevToolsShortcut(window) {
|
||||
if (!isInspectShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
toggleDevTools(window)
|
||||
})
|
||||
@@ -4756,22 +4705,22 @@ function installPreviewShortcut(window) {
|
||||
// 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.
|
||||
import {
|
||||
clampZoomLevel,
|
||||
applyZoomLevel,
|
||||
installZoomReassertOnWindowEvents,
|
||||
percentToZoomLevel,
|
||||
ZOOM_STORAGE_KEY,
|
||||
zoomLevelToPercent
|
||||
zoomLevelToPercent,
|
||||
zoomWiringForWindowKind
|
||||
} from './zoom'
|
||||
|
||||
function setAndPersistZoomLevel(window, zoomLevel) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const next = clampZoomLevel(zoomLevel)
|
||||
window.webContents.setZoomLevel(next)
|
||||
// Keep any open settings UI in sync, including changes made via the
|
||||
// keyboard shortcuts or the View menu.
|
||||
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
|
||||
|
||||
// Apply + notify in one funnel so the settings UI stays in sync, including
|
||||
// changes made via the keyboard shortcuts or the View menu.
|
||||
const next = applyZoomLevel(window.webContents, zoomLevel)
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
|
||||
@@ -4783,6 +4732,7 @@ function restorePersistedZoomLevel(window) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
|
||||
@@ -4791,8 +4741,10 @@ function restorePersistedZoomLevel(window) {
|
||||
if (stored == null || !window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const level = clampZoomLevel(Number(stored))
|
||||
window.webContents.setZoomLevel(level)
|
||||
|
||||
// Notify the renderer too — otherwise the Appearance UI Scale control
|
||||
// can stay stuck at 100% even though the window zoom was restored.
|
||||
applyZoomLevel(window.webContents, Number(stored))
|
||||
})
|
||||
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
|
||||
}
|
||||
@@ -4867,6 +4819,7 @@ function installContextMenu(window) {
|
||||
if (template.length) {
|
||||
template.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
template.push(
|
||||
{
|
||||
label: 'Open Link',
|
||||
@@ -5020,6 +4973,7 @@ function getOauthSession() {
|
||||
if (oauthSession || !app.isReady()) {
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
|
||||
|
||||
return oauthSession
|
||||
@@ -5035,6 +4989,7 @@ async function hasOauthSessionCookie(baseUrl) {
|
||||
if (!sess) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = new URL(baseUrl)
|
||||
|
||||
try {
|
||||
@@ -5067,6 +5022,7 @@ async function hasLiveOauthSession(baseUrl) {
|
||||
if (!sess) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = new URL(baseUrl)
|
||||
|
||||
try {
|
||||
@@ -5149,6 +5105,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (pollTimer) {
|
||||
@@ -5318,6 +5275,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
const statusCode = res.statusCode || 500
|
||||
@@ -5356,6 +5314,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
@@ -5363,6 +5322,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
if (body) {
|
||||
request.write(body)
|
||||
}
|
||||
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
@@ -5768,6 +5728,7 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
|
||||
} = {
|
||||
mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local'
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
||||
if (url) {
|
||||
@@ -6169,7 +6130,7 @@ async function fetchJsonForProfile(profile, path) {
|
||||
}
|
||||
|
||||
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
|
||||
async function requestJsonForProfile(profile: string, path: string, method: Method, body?: string) {
|
||||
async function requestJsonForProfile(profile: string, path: string, method: string, body?: string) {
|
||||
const conn = await ensureBackend(profile)
|
||||
const url = `${conn.baseUrl}${path}`
|
||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||
@@ -6322,19 +6283,7 @@ function resetBootProgressForReconnect() {
|
||||
}
|
||||
|
||||
function stopBackendChild(child) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||
forceKillProcessTree(child.pid)
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS })
|
||||
}
|
||||
|
||||
// Soft gateway-mode apply: tear down the primary without resetting boot UI or
|
||||
@@ -6469,6 +6418,7 @@ function touchPoolBackend(profile) {
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry = backendPool.get(key)
|
||||
|
||||
if (entry) {
|
||||
@@ -6484,6 +6434,7 @@ function evictLruPoolBackends(keep) {
|
||||
if (backendPool.size <= keep) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const evictable = [...backendPool.entries()]
|
||||
@@ -6496,6 +6447,7 @@ function evictLruPoolBackends(keep) {
|
||||
if (removable <= 0) {
|
||||
break
|
||||
}
|
||||
|
||||
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
|
||||
stopPoolBackend(profile)
|
||||
removable -= 1
|
||||
@@ -6506,6 +6458,7 @@ function startPoolIdleReaper() {
|
||||
if (poolIdleReaper) {
|
||||
return
|
||||
}
|
||||
|
||||
poolIdleReaper = setInterval(() => {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -6658,6 +6611,7 @@ function stopPoolBackend(profile) {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
backendPool.delete(profile)
|
||||
stopBackendChild(entry.process)
|
||||
}
|
||||
@@ -6668,6 +6622,7 @@ async function teardownPoolBackendAndWait(profile) {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
backendPool.delete(profile)
|
||||
|
||||
stopBackendChild(entry.process)
|
||||
@@ -6681,59 +6636,38 @@ function stopAllPoolBackends() {
|
||||
}
|
||||
}
|
||||
|
||||
function profileNameFromDeleteRequest(request) {
|
||||
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
raw = decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = raw.trim()
|
||||
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (name.toLowerCase() === 'default') {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
// Returns the profile name whose backend was torn down, or null when the
|
||||
// request is not a profile-delete. The caller uses this to skip ensureBackend
|
||||
// for the just-torn-down profile — otherwise ensureBackend respawns a pool
|
||||
// backend whose ensure_hermes_home() recreates the deleted profile directory.
|
||||
//
|
||||
// The routing *decision* (which branch fires, what profile name gets
|
||||
// returned) lives in the pure decideProfileDeleteAction() in
|
||||
// profile-delete-routing.ts; this function only performs the side effects
|
||||
// that decision calls for.
|
||||
async function prepareProfileDeleteRequest(request) {
|
||||
const profile = profileNameFromDeleteRequest(request)
|
||||
|
||||
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
|
||||
const decision = decideProfileDeleteAction(profile, {
|
||||
isDefaultProfile: p => p === 'default',
|
||||
isValidProfileName: p => PROFILE_NAME_RE.test(p),
|
||||
primaryProfileKey
|
||||
})
|
||||
|
||||
if (decision.action === 'noop') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (profile === primaryProfileKey()) {
|
||||
if (decision.action === 'teardown-primary') {
|
||||
writeActiveDesktopProfile('default')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
|
||||
return profile
|
||||
return decision.profile
|
||||
}
|
||||
|
||||
await teardownPoolBackendAndWait(profile)
|
||||
await teardownPoolBackendAndWait(decision.profile)
|
||||
|
||||
return profile
|
||||
return decision.profile
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
@@ -7014,6 +6948,7 @@ function focusWindow(win) {
|
||||
if (!win.isVisible()) {
|
||||
win.show()
|
||||
}
|
||||
|
||||
win.focus()
|
||||
}
|
||||
|
||||
@@ -7060,7 +6995,7 @@ function spawnSecondaryWindow({
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
@@ -7170,9 +7105,9 @@ function spawnPetOverlayWindow(bounds) {
|
||||
// Not supported everywhere — best effort.
|
||||
}
|
||||
|
||||
// Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it
|
||||
// Pet overlay opts out of global UI zoom (see zoomWiringForWindowKind): it
|
||||
// owns its window-fit + scale, and inheriting zoom would crop the sprite.
|
||||
wireCommonWindowHandlers(win, { zoom: false })
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('petOverlay'))
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
@@ -7304,7 +7239,7 @@ function createWindow() {
|
||||
// window-all-closed from quitting on Windows/Linux).
|
||||
mainWindow.on('closed', () => closePetOverlay())
|
||||
|
||||
wireCommonWindowHandlers(mainWindow)
|
||||
wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat'))
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||
@@ -7451,6 +7386,7 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
|
||||
})
|
||||
|
||||
@@ -7918,7 +7854,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// backend instead of spawning a fresh pool backend. A freshly spawned
|
||||
// backend calls ensure_hermes_home() which recreates the profile directory,
|
||||
// defeating the deletion and leaving a zombie process.
|
||||
const routeProfile = tornDownProfile ? null : profile
|
||||
const routeProfile = resolveRouteProfile(tornDownProfile, profile)
|
||||
const connection = await ensureBackend(routeProfile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
@@ -7952,6 +7888,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
@@ -7967,6 +7904,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
focusWindow(mainWindow)
|
||||
|
||||
if (payload?.sessionId) {
|
||||
@@ -7977,6 +7915,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const action = actions[index]
|
||||
|
||||
if (action?.id) {
|
||||
@@ -8527,6 +8466,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
|
||||
listBaseBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
@@ -8774,6 +8717,7 @@ async function getUninstallSummary() {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
@@ -8968,6 +8912,7 @@ function handleDeepLink(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
@@ -8997,6 +8942,7 @@ function handleDeepLink(url) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.focus()
|
||||
mainWindow.webContents.send('hermes:deep-link', payload)
|
||||
rememberLog(`[deeplink] delivered ${kind}/${name}`)
|
||||
@@ -9053,6 +8999,7 @@ if (!_gotSingleInstanceLock) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Regression coverage for the OAuth-session Electron net.request path.
|
||||
*
|
||||
* Electron net rejects manual Content-Length/Host headers with
|
||||
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
|
||||
* this guard is scoped to fetchJsonViaOauthSession only.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
function extractFetchJsonViaOauthSession() {
|
||||
const start = source.indexOf('function fetchJsonViaOauthSession')
|
||||
const end = source.indexOf('// Mint a single-use WS ticket', start)
|
||||
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
|
||||
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
|
||||
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
|
||||
const fn = extractFetchJsonViaOauthSession()
|
||||
|
||||
assert.match(fn, /electronNet\.request/)
|
||||
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
|
||||
assert.match(fn, /request\.write\(body\)/)
|
||||
})
|
||||
@@ -117,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
||||
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
||||
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
|
||||
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
|
||||
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
||||
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
||||
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const ELECTRON_DIR = import.meta.dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepareProfileDeleteRequest must return the torn-down profile name so the
|
||||
// caller can skip ensureBackend for that profile (issue #52279).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// Locate the function definition and its closing brace.
|
||||
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
|
||||
assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
|
||||
|
||||
// The function must contain "return profile" (pool and primary paths).
|
||||
const fnBody = source.slice(fnStart, fnStart + 800)
|
||||
const returnProfileCount = (fnBody.match(/return profile/g) || []).length
|
||||
assert.ok(
|
||||
returnProfileCount >= 2,
|
||||
`expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
|
||||
)
|
||||
|
||||
// The early-exit guard must return null (not void/undefined).
|
||||
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
|
||||
})
|
||||
|
||||
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The handler must capture prepareProfileDeleteRequest's return value.
|
||||
assert.match(
|
||||
source,
|
||||
/const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
|
||||
'handler should capture the return value of prepareProfileDeleteRequest'
|
||||
)
|
||||
|
||||
// The handler must use the return value to skip ensureBackend for the
|
||||
// torn-down profile, routing to the primary (null) instead.
|
||||
assert.match(
|
||||
source,
|
||||
/const routeProfile = tornDownProfile \? null : profile/,
|
||||
'handler should route to primary backend when a profile was just torn down'
|
||||
)
|
||||
|
||||
// ensureBackend must be called with the conditional route profile.
|
||||
assert.match(
|
||||
source,
|
||||
/const connection = await ensureBackend\(routeProfile\)/,
|
||||
'handler should pass routeProfile (not raw profile) to ensureBackend'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// profileNameFromDeleteRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('profileNameFromDeleteRequest parses a DELETE /api/profiles/<name> path', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/worker' }), 'worker')
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest lowercases the profile name', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/Worker' }), 'worker')
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for non-DELETE methods', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'GET', path: '/api/profiles/worker' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null when the path does not match', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/sessions' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for an empty/whitespace name', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%20' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for an undecodable path segment', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%E0%A4%A' }), null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decideProfileDeleteAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const deps = {
|
||||
isDefaultProfile: p => p === 'default',
|
||||
isValidProfileName: p => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(p),
|
||||
primaryProfileKey: () => 'primary-profile'
|
||||
}
|
||||
|
||||
test('decideProfileDeleteAction is a noop for the default profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('default', deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction is a noop for null (no profile parsed)', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction(null, deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction is a noop for an invalid profile name', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('Not Valid!', deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction tears down the primary backend for the primary profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('primary-profile', deps), {
|
||||
action: 'teardown-primary',
|
||||
profile: 'primary-profile'
|
||||
})
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction tears down the pool backend for any other valid profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('worker', deps), { action: 'teardown-pool', profile: 'worker' })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveRouteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('resolveRouteProfile routes to the primary backend (null) when a profile was torn down', () => {
|
||||
assert.equal(resolveRouteProfile('worker', 'other-profile'), null)
|
||||
})
|
||||
|
||||
test('resolveRouteProfile passes the requested profile through when nothing was torn down', () => {
|
||||
assert.equal(resolveRouteProfile(null, 'other-profile'), 'other-profile')
|
||||
})
|
||||
|
||||
test('resolveRouteProfile passes through undefined when nothing was torn down and no profile was requested', () => {
|
||||
assert.equal(resolveRouteProfile(null, undefined), undefined)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
// Profile-delete routing logic for the `hermes:api` IPC handler.
|
||||
//
|
||||
// When the renderer issues DELETE /api/profiles/<name>, the handler must
|
||||
// tear down that profile's backend (primary window backend or pool backend)
|
||||
// and then route the *next* request away from the just-deleted profile's
|
||||
// pool backend -- spawning a fresh one would call ensure_hermes_home() and
|
||||
// recreate the profile directory the delete just removed, leaving a zombie
|
||||
// process behind (issue #52279).
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Parse a `hermes:api` request into the profile name a DELETE targets, or
|
||||
* null when the request is not a profile-delete at all (wrong method, wrong
|
||||
* path, empty/invalid name).
|
||||
*/
|
||||
export function profileNameFromDeleteRequest(request) {
|
||||
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
raw = decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = raw.trim()
|
||||
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (name.toLowerCase() === 'default') {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
export type ProfileDeleteAction = 'noop' | 'teardown-primary' | 'teardown-pool'
|
||||
|
||||
export interface ProfileDeleteDecision {
|
||||
action: ProfileDeleteAction
|
||||
profile: string | null
|
||||
}
|
||||
|
||||
export interface ProfileDeleteDecisionDeps {
|
||||
isDefaultProfile: (profile: string) => boolean
|
||||
isValidProfileName: (profile: string) => boolean
|
||||
primaryProfileKey: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision logic for prepareProfileDeleteRequest: given the parsed
|
||||
* profile name (or null), decide which side-effecting branch the caller
|
||||
* should take and what profile name it should ultimately report as
|
||||
* torn-down. No I/O, no async -- the caller performs the actual teardown
|
||||
* based on `action`.
|
||||
*/
|
||||
export function decideProfileDeleteAction(
|
||||
profile: string | null,
|
||||
deps: ProfileDeleteDecisionDeps
|
||||
): ProfileDeleteDecision {
|
||||
if (!profile || deps.isDefaultProfile(profile) || !deps.isValidProfileName(profile)) {
|
||||
return { action: 'noop', profile: null }
|
||||
}
|
||||
|
||||
if (profile === deps.primaryProfileKey()) {
|
||||
return { action: 'teardown-primary', profile }
|
||||
}
|
||||
|
||||
return { action: 'teardown-pool', profile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the next `hermes:api` request away from the primary/window backend
|
||||
* whenever a profile was just torn down -- otherwise ensureBackend would
|
||||
* spawn a fresh pool backend for the deleted profile, whose
|
||||
* ensure_hermes_home() recreates the directory the delete just removed.
|
||||
*/
|
||||
export function resolveRouteProfile(
|
||||
tornDownProfile: string | null,
|
||||
profile: string | undefined
|
||||
): string | null | undefined {
|
||||
return tornDownProfile ? null : profile
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
MACOS_TAHOE_DARWIN_MAJOR,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
isPidAlive,
|
||||
markerPath,
|
||||
@@ -40,6 +41,7 @@ const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" =
|
||||
|
||||
const DEAD: typeof process.kill = () => {
|
||||
const err = new Error('no such process')
|
||||
|
||||
;(err as any).code = 'ESRCH'
|
||||
throw err
|
||||
}
|
||||
@@ -93,6 +95,7 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => {
|
||||
test('isPidAlive: EPERM counts as alive (process owned by another user)', () => {
|
||||
const eperm = () => {
|
||||
const err = new Error('operation not permitted')
|
||||
|
||||
;(err as any).code = 'EPERM'
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildRelaunchScript,
|
||||
|
||||
@@ -63,6 +63,7 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
|
||||
if (!execPath || !updateRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
|
||||
const unpacked = path.join(releaseDir, unpackedDirName(platform))
|
||||
const normalizedExec = path.resolve(String(execPath))
|
||||
@@ -116,6 +117,7 @@ function sandboxPreflight(unpackedDir, statSync) {
|
||||
if (!unpackedDir) {
|
||||
return { ok: false, reason: 'no-unpacked-dir', path: null }
|
||||
}
|
||||
|
||||
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
|
||||
let st
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
canonicalGitHubRemote,
|
||||
|
||||
@@ -22,6 +22,7 @@ function canonicalGitHubRemote(url) {
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let value = String(url).trim()
|
||||
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
computeWindowOptions,
|
||||
@@ -118,8 +119,8 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
|
||||
// ─── debounce ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
test('debounce coalesces a burst into one trailing run', () => {
|
||||
vi.useFakeTimers()
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
@@ -130,14 +131,16 @@ test('debounce coalesces a burst into one trailing run', t => {
|
||||
d()
|
||||
d()
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(249)
|
||||
vi.advanceTimersByTime(249)
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
test('debounce.flush runs now and cancels the pending timer', () => {
|
||||
vi.useFakeTimers()
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
@@ -147,6 +150,8 @@ test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
d()
|
||||
d.flush()
|
||||
assert.equal(calls, 1)
|
||||
t.mock.timers.tick(1000)
|
||||
vi.advanceTimersByTime(1000)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ function onScreen(bounds, displays) {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
|
||||
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
|
||||
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { stopBackendChild } from './backend-child'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({}, true), { windowsHide: true })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions preserves an existing windowsHide:false on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: false }, true), { windowsHide: false })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions preserves an existing windowsHide:true on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: true }, true), { windowsHide: true })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions leaves options unchanged off Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({}, false), {})
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ stdio: 'ignore' }, false), { stdio: 'ignore' })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions merges windowsHide alongside other options on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, true), {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true
|
||||
})
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions defaults isWindows from process.platform when omitted', () => {
|
||||
const result = hiddenWindowsChildOptions({})
|
||||
const expectedHide = process.platform === 'win32'
|
||||
|
||||
assert.equal(Boolean(result.windowsHide), expectedHide)
|
||||
})
|
||||
|
||||
function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) {
|
||||
const calls: string[] = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
child: {
|
||||
kill: (signal: string) => {
|
||||
calls.push(signal)
|
||||
},
|
||||
killed: overrides.killed ?? false,
|
||||
pid: 'pid' in overrides ? overrides.pid : 1234
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('stopBackendChild tree-kills on Windows when the child has a pid', () => {
|
||||
const { child, calls } = makeChild({ pid: 4242 })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(treeKillCalls, [4242])
|
||||
assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken')
|
||||
})
|
||||
|
||||
test('stopBackendChild sends SIGTERM on non-Windows platforms', () => {
|
||||
const { child, calls } = makeChild({ pid: 4242 })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: false
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, ['SIGTERM'])
|
||||
assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows')
|
||||
})
|
||||
|
||||
test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => {
|
||||
const { child, calls } = makeChild({ pid: null })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, ['SIGTERM'])
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild is a no-op for an already-killed child', () => {
|
||||
const { child, calls } = makeChild({ killed: true })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild is a no-op for a null/undefined child', () => {
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
|
||||
stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
|
||||
})
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild swallows errors thrown by the kill strategy', () => {
|
||||
const child = {
|
||||
kill: () => {
|
||||
throw new Error('ESRCH: no such process')
|
||||
},
|
||||
killed: false,
|
||||
pid: 99
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: () => {},
|
||||
isWindows: false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* windows-child-options.ts
|
||||
*
|
||||
* Shared helper for opting Windows child processes (spawn/execFileSync) into
|
||||
* a hidden console. Windows spawns a visible console window per child by
|
||||
* default; every desktop-launched helper process (git, curl, taskkill, the
|
||||
* backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide:
|
||||
* true` so the user doesn't see consoles flashing on screen.
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so it
|
||||
* can be unit-tested directly for both platforms without reading source
|
||||
* text, and so main.ts and bootstrap-runner.ts share exactly one
|
||||
* implementation instead of each defining their own copy.
|
||||
*/
|
||||
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
|
||||
|
||||
/**
|
||||
* Merge `windowsHide: true` into `options` when running on Windows, unless
|
||||
* the caller already specified a `windowsHide` value (which is preserved
|
||||
* as-is, including an explicit `false` for cases that intentionally want a
|
||||
* visible/interactive console). No-op on non-Windows platforms.
|
||||
*
|
||||
* @param options - spawn/execFileSync options to (possibly) augment.
|
||||
* @param isWindows - defaults to the real platform check; injectable for
|
||||
* tests so both branches can be exercised without mocking process.platform.
|
||||
*/
|
||||
export function hiddenWindowsChildOptions(
|
||||
options: any = {},
|
||||
isWindows: boolean = process.platform === 'win32'
|
||||
): ExecFileSyncOptionsWithStringEncoding {
|
||||
if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options as any
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true } as any
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// TODO FIXME these tests all grep source code for specific things. This is an antipattern.
|
||||
// Tests should NEVER read src, only assert behavior.
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const match = needle instanceof RegExp ? needle.exec(source) : null
|
||||
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
snippet,
|
||||
/hiddenWindowsChildOptions\(/,
|
||||
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||
)
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
||||
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
|
||||
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
|
||||
})
|
||||
|
||||
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The flash fix is structural: the backend runs as a console-subsystem
|
||||
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
|
||||
// owns ONE windowless console that every descendant spawn inherits. Launching
|
||||
// it as GUI-subsystem pythonw.exe is what made each child allocate (and flash)
|
||||
// its own console, so the backend command must never be pythonw.
|
||||
assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe')
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function getNoConsoleVenvPython\b/,
|
||||
'pythonw-conversion helper should be gone; console python is launched directly'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function applyWindowsNoConsoleSpawnHints\b/,
|
||||
'pythonw spawn-hint rewriter should be gone'
|
||||
)
|
||||
|
||||
// Console python restores stdout, so the port is announced on the normal
|
||||
// HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set.
|
||||
assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path')
|
||||
|
||||
// Both desktop backend launches must still go through hiddenWindowsChildOptions
|
||||
// so the single backend console is created windowless.
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
})
|
||||
|
||||
test('desktop backend teardown tree-kills Windows backend descendants', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
const helperIndex = source.indexOf('function stopBackendChild(child)')
|
||||
assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
|
||||
const helperSnippet = source.slice(helperIndex, helperIndex + 500)
|
||||
assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/)
|
||||
assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/)
|
||||
assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/)
|
||||
|
||||
const resetIndex = source.indexOf('function resetHermesConnection()')
|
||||
assert.notEqual(resetIndex, -1, 'missing resetHermesConnection')
|
||||
const resetSnippet = source.slice(resetIndex, resetIndex + 300)
|
||||
assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/)
|
||||
assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
|
||||
|
||||
const quitIndex = source.indexOf("app.on('before-quit'")
|
||||
assert.notEqual(quitIndex, -1, 'missing before-quit handler')
|
||||
const quitSnippet = source.slice(quitIndex, quitIndex + 900)
|
||||
assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/)
|
||||
assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
// Unit tests for the pure Windows `hermes` resolution helpers extracted from
|
||||
// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs
|
||||
// that caused desktop reinstall loops:
|
||||
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
|
||||
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
|
||||
// the real hermes.cmd/hermes.exe.
|
||||
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
|
||||
// signal), not just the hermes.exe console-script shim, or healthy
|
||||
// installs get forced into a destructive --repair.
|
||||
// 3. resolveVenvHermesCommand() — must probe the venv python via
|
||||
// canImportHermesCli() before trusting it, or a broken venv gets
|
||||
// re-selected forever instead of falling through to bootstrap.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
|
||||
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
|
||||
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
|
||||
|
||||
assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first')
|
||||
assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return')
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => {
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), [''])
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
|
||||
})
|
||||
|
||||
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
|
||||
return {
|
||||
isWindows: true,
|
||||
isCommandScript: () => false,
|
||||
fileExists: () => true,
|
||||
directoryExists: () => false,
|
||||
canImportHermesCli: () => true,
|
||||
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
|
||||
getVenvSitePackagesEntries: () => [],
|
||||
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
|
||||
hermesHome: '/fake/hermes-home',
|
||||
resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'),
|
||||
dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/',
|
||||
basename: (p: string) => p.slice(p.lastIndexOf('/') + 1),
|
||||
rememberLog: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveVenvHermesCommand: returns null off Windows', () => {
|
||||
const deps = makeDeps({ isWindows: false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
|
||||
const deps = makeDeps({ isCommandScript: () => true })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
|
||||
const deps = makeDeps({ fileExists: () => false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
|
||||
let probed = false
|
||||
|
||||
const deps = makeDeps({
|
||||
canImportHermesCli: (python: string) => {
|
||||
probed = true
|
||||
assert.equal(python, '/root/venv/Scripts/python.exe')
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
|
||||
|
||||
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
|
||||
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
|
||||
const deps = makeDeps()
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
|
||||
|
||||
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
|
||||
assert.equal(result.command, '/root/venv/Scripts/python.exe')
|
||||
assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0'])
|
||||
assert.equal(result.bootstrap, false)
|
||||
assert.equal(result.kind, 'python')
|
||||
assert.equal(result.shell, false)
|
||||
assert.deepEqual(result.env, { FAKE_ENV: '1' })
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
|
||||
})
|
||||
|
||||
// ── getVenvSitePackagesEntries ─────────────────────────────────────────────
|
||||
|
||||
test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => {
|
||||
const expected = path.join('C:\\venv', 'Lib', 'site-packages')
|
||||
|
||||
const result = getVenvSitePackagesEntries('C:\\venv', {
|
||||
isWindows: true,
|
||||
directoryExists: p => p === expected
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [expected])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => {
|
||||
const result = getVenvSitePackagesEntries('C:\\venv', {
|
||||
isWindows: true,
|
||||
directoryExists: () => false
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: p => p === '/venv/lib/python3.12/site-packages',
|
||||
readFile: () => 'version_info = 3.12.1\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, ['/venv/lib/python3.12/site-packages'])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => true,
|
||||
readFile: () => undefined
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => true,
|
||||
readFile: () => 'home = /usr/bin\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => false,
|
||||
readFile: () => 'version_info = 3.11\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => {
|
||||
assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), [])
|
||||
assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), [])
|
||||
assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), [])
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* windows-hermes-path.ts
|
||||
*
|
||||
* Pure, dependency-injected pieces of Windows `hermes` resolution pulled out
|
||||
* of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
* unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one
|
||||
* of the Windows resolution bugs that caused desktop reinstall loops:
|
||||
*
|
||||
* 1. buildPathExtCandidates() — findOnPath() tried the empty extension
|
||||
* FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real
|
||||
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
|
||||
* the desktop fell through to a spurious bootstrap/repair. The fix:
|
||||
* PATHEXT extensions first, empty extension LAST.
|
||||
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
|
||||
* --update vs the destructive --repair by checking ONLY
|
||||
* venv\Scripts\hermes.exe (the console-script shim, written at the END
|
||||
* of venv setup and absent in interrupted states), so it escalated to a
|
||||
* full venv recreate even on healthy installs. The fix: gate on ANY
|
||||
* real-install signal, not just the shim.
|
||||
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
|
||||
* the venv python with NO runtime probe (bypassing the caller's
|
||||
* --version check too), so a venv broken mid-update (e.g. missing
|
||||
* python-dotenv) was re-selected forever: Retry / "Repair install"
|
||||
* resolved the same dead interpreter instead of falling through to the
|
||||
* bootstrap installer. The fix: probe-before-trust.
|
||||
*
|
||||
* Kept in a standalone ts module (no Electron imports, dependencies passed
|
||||
* as parameters) so it can be unit-tested with `node --test` without
|
||||
* mocking Electron or the filesystem, same pattern as backend-probes.ts and
|
||||
* backend-command.ts.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Build the ordered list of extensions findOnPath() should try when
|
||||
* resolving a bare command name off PATH.
|
||||
*
|
||||
* On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by
|
||||
* default) BEFORE the bare/empty-extension name: a real command resolves via
|
||||
* its .exe/.cmd per Windows command-resolution semantics, and an
|
||||
* extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must
|
||||
* not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so
|
||||
* callers that already include the extension (py.exe, pwsh.exe,
|
||||
* powershell.exe) still resolve.
|
||||
*
|
||||
* On non-Windows platforms there is no PATHEXT concept: only the bare name
|
||||
* is tried.
|
||||
*
|
||||
* @param {string | undefined} pathext - process.env.PATHEXT (or undefined).
|
||||
* @param {boolean} isWindows
|
||||
* @returns {string[]} extensions to try, in order, always ending in ''.
|
||||
*/
|
||||
export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] {
|
||||
if (!isWindows) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the Windows bootstrap-recovery updater invocation: the gentle
|
||||
* in-place --update when ANY real-install signal is present, the
|
||||
* destructive --repair (full venv recreate) otherwise.
|
||||
*
|
||||
* haveRealInstall must be computed by the caller from ALL real-install
|
||||
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
|
||||
* marker) — gating on just the hermes.exe console-script shim alone is the
|
||||
* regression this function's callers must avoid: that shim is written at
|
||||
* the END of venv setup and is absent in exactly the interrupted/quarantined
|
||||
* states this recovery exists to heal.
|
||||
*
|
||||
* @param {boolean} haveRealInstall
|
||||
* @param {string} branch
|
||||
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
|
||||
*/
|
||||
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
|
||||
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the site-packages directory entries for a Python venv.
|
||||
*
|
||||
* On Windows, venv layout is `<venvRoot>/Lib/site-packages`.
|
||||
* On POSIX, it's `<venvRoot>/lib/python<version>/site-packages` where
|
||||
* `<version>` (e.g. `3.12`) is read from the venv's `pyvenv.cfg`
|
||||
* `version_info` field.
|
||||
*
|
||||
* Returns only directories that actually exist on disk. Returns an empty
|
||||
* array when `venvRoot` is falsy or no matching site-packages dir is found.
|
||||
*
|
||||
* Extracted from main.ts so the platform branching can be tested without
|
||||
* reading source text. `isWindows` and `directoryExists` are injectable;
|
||||
* `readFile` defaults to `fs.readFileSync` but can be overridden for tests.
|
||||
*/
|
||||
export function getVenvSitePackagesEntries(
|
||||
venvRoot: string | undefined | null,
|
||||
opts: {
|
||||
isWindows?: boolean
|
||||
directoryExists?: (p: string) => boolean
|
||||
readFile?: (p: string) => string | undefined
|
||||
} = {}
|
||||
): string[] {
|
||||
const entries: string[] = []
|
||||
|
||||
if (!venvRoot) {
|
||||
return entries
|
||||
}
|
||||
|
||||
const isWindows = opts.isWindows ?? process.platform === 'win32'
|
||||
|
||||
const directoryExists = opts.directoryExists ?? ((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const readFile = opts.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg'))
|
||||
|
||||
const version = (() => {
|
||||
if (!cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
|
||||
|
||||
return match ? match[1].trim() : null
|
||||
})()
|
||||
|
||||
if (version) {
|
||||
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export interface ResolveVenvHermesCommandDeps {
|
||||
isWindows: boolean
|
||||
isCommandScript: (command: string) => boolean
|
||||
fileExists: (filePath: string) => boolean
|
||||
directoryExists: (filePath: string) => boolean
|
||||
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
|
||||
getVenvPython: (venvRoot: string) => string
|
||||
getVenvSitePackagesEntries: (venvRoot: string) => string[]
|
||||
buildDesktopBackendEnv: (opts: {
|
||||
hermesHome: string
|
||||
pythonPathEntries: string[]
|
||||
venvRoot: string
|
||||
}) => Record<string, string>
|
||||
hermesHome: string
|
||||
resolvePath: (...segments: string[]) => string
|
||||
dirname: (p: string) => string
|
||||
basename: (p: string) => string
|
||||
rememberLog?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim
|
||||
* (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying
|
||||
* venv python invoked as `python -m hermes_cli.main <backendArgs>` — but
|
||||
* ONLY after smoke-testing that interpreter with canImportHermesCli(). A
|
||||
* venv whose update died mid-`pip install` still has python.exe + hermes.exe
|
||||
* on disk, but the backend dies on its first import (e.g.
|
||||
* ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it
|
||||
* unprobed also bypasses the caller's `--version` probe, so Retry/"Repair
|
||||
* install" re-resolves the same broken venv forever instead of falling
|
||||
* through to the bootstrap installer.
|
||||
*
|
||||
* Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so
|
||||
* a healthy source-tree venv passes.
|
||||
*
|
||||
* Returns null when `command` is not a venv hermes shim, the underlying
|
||||
* python doesn't exist, or the import probe fails. Otherwise returns the
|
||||
* resolved backend descriptor.
|
||||
*/
|
||||
export function resolveVenvHermesCommand(
|
||||
command: string,
|
||||
backendArgs: string[],
|
||||
deps: ResolveVenvHermesCommandDeps
|
||||
): {
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
bootstrap: false
|
||||
env: Record<string, string>
|
||||
kind: 'python'
|
||||
root: string
|
||||
shell: false
|
||||
} | null {
|
||||
const {
|
||||
isWindows,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome,
|
||||
resolvePath,
|
||||
dirname,
|
||||
basename,
|
||||
rememberLog
|
||||
} = deps
|
||||
|
||||
if (!isWindows || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = resolvePath(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = dirname(resolved)
|
||||
|
||||
if (basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = dirname(venvRoot)
|
||||
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog?.(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Regression guards for Windows `hermes` resolution in main.ts.
|
||||
//
|
||||
// main.ts has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.ts). They pin the two Windows
|
||||
// resolution bugs that caused desktop reinstall loops:
|
||||
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
|
||||
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
|
||||
// shim then failed the --version probe and the desktop fell through to a
|
||||
// spurious bootstrap/repair.
|
||||
// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
|
||||
// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
|
||||
// shim, written at the END of venv setup and absent in interrupted
|
||||
// states), so it escalated to a full venv recreate even on healthy
|
||||
// installs.
|
||||
// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO
|
||||
// runtime probe (bypassing the caller's --version check too), so a venv
|
||||
// broken mid-update (e.g. missing python-dotenv) was re-selected forever:
|
||||
// Retry / "Repair install" resolved the same dead interpreter instead of
|
||||
// falling through to the bootstrap installer.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readMain() {
|
||||
return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
|
||||
const source = readMain()
|
||||
// Fixed order: PATHEXT first, empty string LAST.
|
||||
assert.match(
|
||||
source,
|
||||
/\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
|
||||
'extensions array must end with the empty string, not start with it'
|
||||
)
|
||||
// The buggy empty-first order must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\['', \.\.\.\(process\.env\.PATHEXT/,
|
||||
'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
|
||||
const source = readMain()
|
||||
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
|
||||
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
|
||||
assert.match(
|
||||
source,
|
||||
/\.hermes-bootstrap-complete/,
|
||||
'recovery must accept the bootstrap-complete marker as a real-install signal'
|
||||
)
|
||||
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
|
||||
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updaterArgs = fileExists\(venvHermes\) \?/,
|
||||
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
|
||||
)
|
||||
})
|
||||
|
||||
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
|
||||
const source = readMain()
|
||||
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
|
||||
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
|
||||
// Slice out just the function body (up to the next top-level function decl)
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
|
||||
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
|
||||
assert.match(
|
||||
body,
|
||||
/canImportHermesCli\(python/,
|
||||
'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' +
|
||||
'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)'
|
||||
)
|
||||
assert.match(
|
||||
body,
|
||||
/return null\s*\n\s*\}\s*\n\s*return \{/,
|
||||
'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung'
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function parseRegQueryValue(stdout, name) {
|
||||
if (!stdout || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
|
||||
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
||||
@@ -70,6 +71,7 @@ function readWindowsUserEnvVar(
|
||||
if (platform !== 'win32' || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
let stdout
|
||||
|
||||
try {
|
||||
@@ -88,6 +90,7 @@ function readWindowsUserEnvVar(
|
||||
if (raw == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expanded = expandWindowsEnvRefs(raw, env).trim()
|
||||
|
||||
return expanded || null
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { isPackagedInstallPath } from './workspace-cwd'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
decodeClipboardImageBase64,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
applyZoomLevel,
|
||||
clampZoomLevel,
|
||||
installZoomReassertOnWindowEvents,
|
||||
percentToZoomLevel,
|
||||
ZOOM_REASSERT_WINDOW_EVENTS,
|
||||
ZOOM_STORAGE_KEY,
|
||||
zoomLevelToPercent
|
||||
zoomLevelToPercent,
|
||||
zoomWiringForWindowKind
|
||||
} from './zoom'
|
||||
|
||||
test('storage key stays stable so persisted zoom survives upgrades', () => {
|
||||
@@ -66,12 +66,14 @@ test('extreme percentages clamp to the level bounds', () => {
|
||||
|
||||
test('installZoomReassertOnWindowEvents wires show and restore', () => {
|
||||
const handlers = new Map()
|
||||
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
on(event, listener) {
|
||||
handlers.set(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
installZoomReassertOnWindowEvents(win, () => {
|
||||
calls += 1
|
||||
@@ -86,12 +88,14 @@ test('installZoomReassertOnWindowEvents wires show and restore', () => {
|
||||
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
|
||||
const handlers = new Map()
|
||||
let destroyed = false
|
||||
|
||||
const win = {
|
||||
isDestroyed: () => destroyed,
|
||||
on(event, listener) {
|
||||
handlers.set(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
installZoomReassertOnWindowEvents(win, () => {
|
||||
calls += 1
|
||||
@@ -101,25 +105,56 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
// Source assertion (see windows-child-process.test.ts for the established
|
||||
// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy
|
||||
// Electron deps, so we assert the wiring contract against source rather than
|
||||
// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI
|
||||
// zoom while chat windows keep it — the whole reason this fix is scoped.
|
||||
test('pet overlay opts out of global UI zoom; chat windows keep it', () => {
|
||||
const electronDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
// The shared helper gates all zoom wiring behind an opt-out flag.
|
||||
assert.match(source, /function wireCommonWindowHandlers\(win, \{ zoom = true \}/)
|
||||
|
||||
// The pet overlay window is the only caller that disables zoom.
|
||||
assert.match(source, /wireCommonWindowHandlers\(win, \{ zoom: false \}\)/)
|
||||
|
||||
// Zoom restore now flows through the shared helper, so createWindow must not
|
||||
// reassert it directly (that would double-fire and drift from session windows).
|
||||
const finishLoad = source.indexOf("mainWindow.webContents.once('did-finish-load'")
|
||||
assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler')
|
||||
const snippet = source.slice(finishLoad, finishLoad + 300)
|
||||
assert.doesNotMatch(snippet, /restorePersistedZoomLevel\(mainWindow\)/)
|
||||
// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay
|
||||
// opts out. Tested via the extracted config — no source-text regex.
|
||||
test('chat windows opt into zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true })
|
||||
})
|
||||
|
||||
test('pet overlay opts out of zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false })
|
||||
})
|
||||
|
||||
test('unknown window kinds default to chat (zoom enabled)', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
|
||||
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
|
||||
})
|
||||
|
||||
// The UI Scale settings control drifts out of sync after a restart when zoom
|
||||
// is applied to the window but the renderer is never told: its $zoomPercent
|
||||
// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and
|
||||
// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom
|
||||
// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a
|
||||
// level always notifies — the regression can't come back by forgetting a send.
|
||||
function fakeWebContents() {
|
||||
const calls: Array<[string, ...unknown[]]> = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]),
|
||||
send: (channel: string, payload: unknown) => calls.push(['send', channel, payload])
|
||||
}
|
||||
}
|
||||
|
||||
test('applyZoomLevel applies the level then notifies the renderer', () => {
|
||||
const wc = fakeWebContents()
|
||||
const applied = applyZoomLevel(wc, 3)
|
||||
|
||||
assert.equal(applied, 3)
|
||||
assert.deepEqual(wc.calls, [
|
||||
['setZoomLevel', 3],
|
||||
['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }]
|
||||
])
|
||||
})
|
||||
|
||||
test('applyZoomLevel clamps garbage before applying and notifying', () => {
|
||||
const wc = fakeWebContents()
|
||||
const applied = applyZoomLevel(wc, 999)
|
||||
const clamped = clampZoomLevel(999)
|
||||
|
||||
assert.equal(applied, clamped)
|
||||
assert.deepEqual(wc.calls, [
|
||||
['setZoomLevel', clamped],
|
||||
['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) {
|
||||
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a clamped zoom level to a webContents AND notify the renderer, in that
|
||||
* order. Every path that changes zoom (user action, restore-on-load, lifecycle
|
||||
* re-assert) funnels through here so the settings UI Scale control can never
|
||||
* drift from the actually-applied level — the bug where restore set the level
|
||||
* but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%.
|
||||
* Returns the clamped level so callers can persist it.
|
||||
*/
|
||||
export function applyZoomLevel(webContents, level) {
|
||||
const clamped = clampZoomLevel(level)
|
||||
webContents.setZoomLevel(clamped)
|
||||
webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) })
|
||||
|
||||
return clamped
|
||||
}
|
||||
|
||||
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
|
||||
// and restored. Re-apply the persisted level on these lifecycle transitions.
|
||||
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
|
||||
@@ -46,7 +62,25 @@ export function installZoomReassertOnWindowEvents(win, reassert) {
|
||||
if (win.isDestroyed?.()) {
|
||||
return
|
||||
}
|
||||
|
||||
reassert()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
|
||||
* global UI zoom; the pet overlay opts out because it sizes its own OS window
|
||||
* to the sprite and inheriting zoom would crop it.
|
||||
*
|
||||
* Extracted so the "pet opts out, everything else opts in" contract is
|
||||
* unit-testable without booting a BrowserWindow or reading source.
|
||||
*/
|
||||
export const ZOOM_WINDOW_CONFIG = {
|
||||
chat: { zoom: true },
|
||||
petOverlay: { zoom: false }
|
||||
} as const
|
||||
|
||||
export function zoomWiringForWindowKind(kind) {
|
||||
return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user