Skip to content

feat(app): embed isolated Hermes profile sessions - #220

Open
Cyb3rb1ade wants to merge 240 commits into
youssofal:mainfrom
Cyb3rb1ade:codex/embedded-hermes-agent-selection-pr
Open

feat(app): embed isolated Hermes profile sessions#220
Cyb3rb1ade wants to merge 240 commits into
youssofal:mainfrom
Cyb3rb1ade:codex/embedded-hermes-agent-selection-pr

Conversation

@Cyb3rb1ade

@Cyb3rb1ade Cyb3rb1ade commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What

  • adds Hermes profile selection in Settings and saved session/agent selection in the native MTPLX UI
  • lets a selected profile accept a first prompt immediately and creates the new Hermes agent on first send
  • launches an MTPLX-owned hermes -p <profile> serve --isolated sidecar for the selected profile
  • connects through authenticated gateway RPC for session discovery, creation, resume, prompt streaming, tools, and approval prompts
  • persists profile/session selection without changing shared Hermes routing
  • includes the approved design and implementation notes

Isolation and lifecycle guarantees

  • applies endpoint, key, and model overrides only to the MTPLX-owned sidecar process
  • leaves the Root Gateway, Telegram routing, and foreign Hermes processes untouched
  • preserves the selected profile config.yaml and .env byte-identically
  • keeps externally active sessions visible but read-only and offers a fresh session instead
  • records exact process identity and ownership before teardown or orphan cleanup
  • fails closed for ambiguous routing, ownership, request IDs, and approvals
  • restores normal behavior by ending only the marked MTPLX sidecar when switching profiles or exiting MTPLX

Validation

  • swift test --scratch-path /tmp/mtplx-hermes-textfield-tests --filter Hermes: 134 passed, 0 failed
  • swift test --scratch-path /tmp/mtplx-hermes-textfield-tests: 663 executed, 1 skipped, 0 failed
  • swift build --scratch-path /tmp/mtplx-hermes-textfield-build --product MTPLXApp: passed with Xcode-beta
  • uv build --wheel --out-dir dist: passed
  • scripts/fresh_venv_smoke.sh: passed
  • git diff --check upstream/main...HEAD: passed
  • required Python selection: 251 passed, 2 failed because upstream tests still expect the former Qwen default while current upstream/main uses Ornith; this PR changes no Python or Python-test files

Scope

This branch is rebuilt directly on current upstream/main and contains only the Hermes app integration, its Swift tests, and the corresponding design/plan documents. Retrieval, embeddings, reranking, packaged-runtime, and local build-environment commits are deliberately excluded.

penta and others added 30 commits June 25, 2026 16:03
Adds an AppKit-level integration test target (MTPLXAppHostTests) that drives
the real ComposerInputTextView.Coordinator against a live NSTextView,
exercising the actual IME mechanism (setMarkedText / insertText).

It exists only to make the fix verifiable in-tree and is reverted by the
final commit of this PR, so the net diff is the fix alone:

  * Check out THIS commit and run
      cd apps/MTPLXApp && swift test --filter ComposerIMEIntegrationTests
    -> testPreeditStaysOutOfBindingUntilCommitted FAILS: the composer has no
       marked-text guard, so IME preedit leaks into the SwiftUI binding.
  * Check out the next commit (the fix) and run the same
    -> it PASSES.

Shippable regression coverage lives in the fix commit as pure-logic unit
tests (MTPLXAppCoreTests/ComposerTextSyncTests); this harness is the
on-machine proof, not a permanent addition to the test surface.
The composer is an NSViewRepresentable bridging a SwiftUI @binding<String>
to an AppKit NSTextView. During IME composition — any input method that
builds a character from intermediate keystrokes: CJK (pinyin, kana-kanji,
hangul), dead-key accents (´ + e -> é), and more — the text view holds
uncommitted "marked text" (the preedit) that mutates every keystroke, while
SwiftUI binding propagation lags a render cycle. Two paths then race:

  - textDidChange published the provisional preedit up into the binding.
  - updateNSView wrote a now-stale binding value back into textView.string,
    which tears down the live marked-text session and drops the characters
    being composed — so non-ASCII input "disappears" in the composer.

Gate both crossings on NSTextView.hasMarkedText(): never publish provisional
preedit, and never overwrite the text view while a composition is in flight.
The committed text arrives in a later textDidChange with no marked text.

The decision logic is extracted into ComposerTextSync in MTPLXAppCore so it
is unit-tested without a live NSTextView, matching the existing test
strategy (logic in Core, views kept thin). ASCII typing is unaffected: it
never enters a marked-text phase.
…-ime-composition

Chat composer: preserve IME composition instead of dropping input
…penalties

Add presence_penalty / frequency_penalty (per-position, vLLM-exact) — fixes youssofal#102
… Penalty dial

Completes the presence/frequency penalty feature that PR youssofal#120 started.
PR youssofal#120 wired server-default flags into the samplers but typed request
fields were still silently dropped (issue youssofal#102): ChatCompletionRequest /
CompletionRequest use extra="allow", so client-sent presence_penalty and
frequency_penalty vanished without effect or error.

- Typed presence_penalty/frequency_penalty fields on ChatCompletionRequest
  and CompletionRequest (range-validated -2..2).
- Threaded through all 11 chat/completions dispatch sites into
  _run_generation -> _generation_params with precedence:
  request value > server default > 0.0 (explicit 0 beats a non-zero
  server default). Gated by client_controls_allowed exactly like
  temperature; observability (request_/effective_presence_penalty) and
  ignored-client-field reporting extended to match.
- _run_generation solo lane gained the params (youssofal#120 covered only the
  AR-batch lane).
- Live settings: presence_penalty/frequency_penalty accepted by
  /v1/mtplx/settings (MTPLXSettingsUpdate, DASHBOARD_MUTABLE_SETTINGS_KEYS,
  _coerce_setting float coercion + range check -> 400 out of range),
  applied onto state.args.default_*_penalty, reported by GET settings.
- Web chat UI: "Presence Penalty" slider (0-2, step 0.05, "off" at 0)
  in the Sampling sidebar, synced to /v1/mtplx/settings like the other
  dials; server-side default_settings extended.
- tests/test_penalty_request_wiring.py: 10 tests covering precedence,
  typed parsing, end-to-end capture through TestClient, controls gating,
  live settings update, range rejection, draft-sampler isolation.

QA (2026-07-02, fans pinned+verified for every generation):
- temp-0 seed-0 litmus: penalties-unset byte-identical to explicit-0;
  presence 2.0 diverges. Server-owned settings path (no client headers)
  proves the app lane too.
- bench tune 192: AR 29.8 / D1 50.8 / D2 55.9 / D3 60.16 BEST vs
  baseline 59.31 (flat-or-better), peak memory identical 15.11 GiB.
- quick suite: all contract lanes flat-or-better vs wave-0 baseline;
  long-tool-history FAIL is the pre-existing unknown-test bug on main.
- pytest 1593 passed / 4 skipped; ruff: only the 4 pre-existing errors.
… start

serve/quickstart accept the flags since PR youssofal#120, but mtplx start (the
documented first-run path) neither exposed nor forwarded them, so a
server default set at start was silently lost. Adds the two flags to the
start parser and carries them through _with_server_policy_args into the
spawned server.
LM Studio-style "Presence Penalty" slider (0-2, step 0.05, default 0,
help text with the Qwen guidance: keep 0 for coding, raise for
anti-repetition/creative) under SAMPLING next to Top P/Top K.

presencePenalty flows end-to-end: MutableSettings (presence_penalty
JSON key) -> MTPLXBackendStore live-settings patch/persist/merge paths
-> daemon /v1/mtplx/settings round-trip -> persisted in
MTPLXAppConfiguration; MTPLXChatClient.ChatRequest carries the field
per-request as well.

QA: swift tests 437 pass; built app + app-owned daemon exercised live -
dial steps propagated to daemon settings (0.2 -> 0.3 observed via GET
/v1/mtplx/settings) and persisted to settings.json; in-app chat with
penalty active streamed at 50.7 tok/s.
Adds presence_penalty to MutableSettings and a NumberField dial
(0-2, step 0.05, with description) in ControlsSidebar; rebuilt
mtplx/dashboard/_static (bun run build).
README server section documents the sampler dials incl. presence
penalty defaults; TROUBLESHOOTING gains "Model Repeats Itself / Loops"
with the Qwen guidance (0 for coding, ~0.5-1.5 for anti-repetition).
…ied, held through postcommit (youssofal#127)

Root causes of the reported fan flakiness (issue youssofal#127 + founder reports
of fans not ramping until output generation):
1. The ramp was issued at generation dispatch — after routing, transcript
   canonicalization, prompt encoding, and queueing — so long prefills ran
   their lead-in on silent fans.
2. SmartFanController.begin_request ran set_thermal_profile synchronously
   under the controller lock: serial subprocess probes with 15 s timeouts
   on the request path, failures swallowed with no verification or retry.
3. Fans restored 0.2 s after the HTTP request finished while the idle
   postcommit re-prefilled the whole conversation at 100% GPU — the
   "fans stop while the Mac is still cooking" report. QA measured this
   phase at 5.5 minutes for a 47k-token conversation.
4. Switching the server to Max mode called smart_fans.restore_now(wait
   =False) BEFORE pinning max: the delayed async smart restore then fired
   AFTER the max pin and silently dropped fans back to auto while the UI
   showed Max — the "max released early" report.

The overhaul:
- SmartFanController is now a desired-state machine driven by one
  dedicated worker thread. begin_request/end_request never block and
  never run a subprocess on the caller thread; the lock only guards
  state. Ramp latency, target verification, actual-RPM verification,
  and attempt counts are tracked and exposed via status().
- After commanding max the worker verifies the daemon accepted the
  target RPM (fan_summary) and retries the command once on failure,
  then polls until the physical ramp is visible (30 s bound). A failed
  ramp logs one actionable line and does not hammer the daemon until a
  new lease generation arrives. wait_for_ramp() gives bench/QA/test
  code a synchronization point.
- New _SmartFanArrivalMiddleware leases the fans the moment a
  generation POST (/v1/chat/completions, /v1/completions, /v1/messages)
  arrives — before body parsing and prompt encoding — and releases when
  the full response (including the stream body) has been sent.
  Registered inside the auth middleware so unauthorized requests never
  ramp. Open WebUI background task probes are skipped.
- The idle postcommit now holds a fan lease from schedule time until
  the job resolves (try/finally, plus release-on-submit-failure), so
  fans stay ramped through the post-generation GPU phase.
- Restore debounce raised 0.2 s -> 2 s so agent tool loops reuse the
  ramp instead of flapping fans between calls.
- Max-mode switch now uses the new smart_fans.detach() (drop leases and
  pending work WITHOUT touching hardware) so the max pin cannot be
  raced back to auto; default-mode switch drains the smart controller
  synchronously before the verified restore.
- /health gains smart_fan_target_verified / smart_fan_actual_ramp_
  verified / smart_fan_ramp_latency_s / smart_fan_actual_ramp_latency_s.
- TROUBLESHOOTING: new section explaining the post-generation
  postcommit GPU phase and the health fields (answers the youssofal#127
  reporter's question directly).

QA (live server, thermalforge status sampled every 0.4 s):
- 186k-char prompt: target RPM flipped to max 0.66 s after request
  arrival (ramp_latency_s 0.19, target verified attempt 1), actual RPM
  7636 by 1.5 s; first token at 229 s — the entire prefill ran on max
  fans (previously the lead-in ran silent).
- Fans held at max through the 5.5-minute idle postcommit after the
  stream closed; restored to auto only after it stored (leases 1 -> 0).
- 3-turn back-to-back session: fans stayed manual across all turns (no
  flap), restored after the session drained.
- Smart->Max switch with active smart state: pin still manual/7826 5 s
  later (old code dropped to auto). Max->default restores verified.
  SIGTERM shutdown restores fans to auto.
- tests/test_thermal.py: 31 pass (5 new: non-blocking begin, retry-once,
  failure-without-hammering, detach-no-hardware, updated lease test).
- Full pytest 1597 passed / 4 skipped; ruff clean on changed files.
…t-party coercion (youssofal#57)

Users loading third-party or legacy artifacts were shown a first-party
canonical id: issue youssofal#57 (Qwen3.6-27B-MTPLX-Optimized reported as
mtplx-qwen36-27b-optimized-speed) and the PR youssofal#77 report (a samuelfaj
35B build served as mtplx-qwen36-27b-optimized-quality).

Four fuzzy inference lanes in default_models.py could each claim a
non-first-party artifact as first-party:
- artifact_role substring matching ("quality"/"speed"/"gdn8"/"fp16") —
  third-party builds made with mtplx forge carry these roles too;
- verified_on.model substring inference (same problem);
- precision_variant=fp16 coercion to the first-party FP16 id;
- quantization-layout inference (Q4 vs Flat8 "upgrading" the legacy
  artifact's identity — the exact youssofal#57 report);
- loose family-name coercion (any "qwen3.6-35b-a3b"+"mtplx" string
  claimed as the first-party 35B artifact).

New contract: a canonical mtplx-* id requires a true first-party match —
an explicit public_model_id/served_model_id/model_id in
mtplx_runtime.json, or an exact first-party name (public id, HF repo id,
released folder name; the loose 9B/35B family matches are now exact
released-name matches, including the CyanKiwi CleanRecipe local build of
the released 35B). Everything else serves under its sanitized actual
artifact name.

Regression matrix added to tests/test_default_models.py: nom666 Qwopus
4bit-Speed/8bit-Quality (real repos from the June triage), the samuelfaj
35B case folded in from PR youssofal#77 (credit wwadge for the report), a 35B
family remix, artifact_role/precision/verified_on non-coercion, the youssofal#57
legacy-name quantization case, and a first-party matrix across all
released ids.

QA: served the real nom666--Qwopus3.6-27B-Coder-MTPLX-4bit-Speed
artifact — startup banner, /health, /v1/models, /v1/mtplx/settings, and
the chat completion "model" field all report
nom666-qwopus3.6-27b-coder-mtplx-4bit-speed (previously mislabeled
lanes). First-party flagship continues to serve
mtplx-qwen36-27b-optimized-speed (same session, waves 2-3 QA servers).
Full pytest 1609 passed / 4 skipped.
…pad embeddings (youssofal#103)

Investigation result first (greedy A/B, temp 0, seed 0, cache bypass,
two near-identical dashboard screenshots, 200- and 600-token runs):
MTP greedy output is byte-identical across D1/D2/D3 with images, both
before and after this change, and the model correctly identifies the
single planted difference. Verify-side exactness with images was never
broken — the depth-dependent hallucinations reported in youssofal#103 at
temperature are sampling variance (different depths consume the RNG
differently, drawing different samples from the same exact
distribution), not distribution corruption. A text-only control also
showed the chunk-size output sensitivity is bf16 chunked-prefill
non-associativity, not vision-specific. Consequently the depth-1 image
gate contemplated in the plan is NOT shipped: exactness is proven, and
capping depth would only slow vision requests down.

The real (draft-side) defect fixed here: _append_mtp_history passed raw
token ids to the MTP head, so the committed history embedded image-pad
tokens where the trunk prefill saw spliced vision rows. The draft head's
history context over image spans was therefore built from meaningless
pad embeddings. Verify authority means this could never corrupt output,
but it degrades draft/trunk alignment over image spans.

- vision/splice.py: new cursor-free spliced_embeddings_for_window (the
  history stream pairs hidden t with token t+1, so its embedding window
  is shifted one token right of the trunk chunk; rows are read at an
  explicit offset via pad prefix counts, leaving the trunk's sequential
  cursor untouched). Shared row-splice helper extracted.
- generation.py: both prompt-history append paths (sustained streaming
  chunk loop + non-sustained full-sequence path) now build the shifted
  spliced window and thread it through _append_mtp_history
  (input_embeddings). Zero change when no vision splice is present.
- runtime.update_mtp_cache / mtp_patch _mtp_core + mtp_update_cache:
  optional input_embeddings overrides embed_tokens(next_token_ids);
  runtime raises instead of silently dropping the rows on MTP backends
  that don't accept them.
- tests: 4 new splice-window tests (row alignment vs the trunk lane,
  offset reads, overflow, no-pad fast path); existing sustained-history
  test stubs extended with the new parameter.

Measured effect (D3 greedy, deterministic per lane, fans pinned):
- exhaustive single-image describe: mean accept prob by depth
  [0.862, 0.720, 0.593] -> [0.903, 0.719, 0.622], accepted 411/567 ->
  415/555 (fewer drafts wasted).
- two-image compare: [0.969, 0.901, 0.851] -> [0.951, 0.896, 0.840]
  (slightly down, within the fluctuation of a changed-history regime).
- outputs byte-identical pre/post in all lanes, as exactness demands.

Full pytest 1613 passed / 4 skipped; ruff clean on changed files.
…ions/actions/setup-python-6.3.0

Bump actions/setup-python from 6.2.0 to 6.3.0
…raphy-48.0.1

Bump cryptography from 48.0.0 to 48.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v6.0.2...v7.0.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [idna](https://github.com/kjd/idna) from 3.13 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](kjd/idna@v3.13...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
…te-1.3.1

Bump starlette from 1.0.0 to 1.3.1
…ions/actions/checkout-7.0.0

build(deps): bump actions/checkout from 6.0.2 to 7.0.0
youssofal#134 by @Jonathangadeaharder, applied locally for v2)

Qwen3_5MoeForConditionalGeneration multimodal checkpoints (Ornith-1.0-35B)
ship the vision tower under model.visual.*; the vision module only knew
mlx-vlm's vision_tower.* layout, so vision_spec_for_model_dir returned None
and image requests got HTTP 400 despite a complete tower in the index.
resolve_vision_prefix() picks the active prefix; text-only repos still
resolve to None. Mirrors the trunk loader's existing _mlx_key remap.

Verified locally: test_vision_tower 22 passed (2 new), compressed_tensors +
artifacts suites green.
…oussofal#133 by @shiningliang, applied locally for v2)

_quickstart_print_dashboard_handoff does `from mtplx.ui import pretty_path`,
but the helper only existed as onboarding._pretty_path — the live-dashboard
lane of `mtplx start` crashed with ImportError right after the model check.
Lazy module __getattr__ re-export keeps mtplx.ui import-cheap.

Verified locally: import crash reproduced on main, gone after; PR's two
regression tests green (conflict with the neighboring Hermes-config tests
resolved by keeping both).
…rMarioYL, applied locally for v2)

Key the finished set by request_id (O(1) dedup vs the O(n) identity scan),
cap retained RequestState objects at 4096, and report the exact cumulative
count via finished_total (snapshot()["finished"] keeps its meaning; new
finished_retained gauge). Long-running servers no longer accumulate every
finished request forever.

Verified locally: test_batching_foundation + test_model_scheduler green
(14 passed), including the new bounded-retention and idempotency pins.
Session-cache v2: boundary-true GDN restores, O(1) RAM restores, SSD
cold tier that survives daemon restarts, store-on-prefill keyed to the
request's token prefix so tool-call turns chain warm instead of
re-prefilling whole transcripts. Turbo profile promoted to the default
for the quantized 27B flagships: NAX verify kernels (vk_k / vk-q8)
plus context-routed compiled verify with a per-model quantization
gate. Long-context kernel wave: packed-GQA verify attention and
commit-first KV donation in compiled verify (64k decode +12%, 128k
17 -> 20+ tok/s, peak memory -8..-16 GB). Stability: the app health
watchdog treats liveness as transport truth and no longer terminates
a healthy daemon on an undecodable /health payload (youssofal#105);
transformers pinned <5.13 after 5.13.0 broke mlx-lm imports on every
fresh install (youssofal#135, youssofal#136, community PR youssofal#137 by @davidtai). Agent
protocol pass: OpenCode plan->build no longer breaks the cache or
hides tools, prefix-stable transcripts, per-request presence and
frequency penalties end to end, served model identity is
contract-match-only (youssofal#57), Hermes config merge-preserved (youssofal#131).
Vision under MTP consumes spliced vision rows in the draft history
(youssofal#103). Smart fan control ramps at request arrival, verifies RPM,
holds through postcommit (youssofal#127). App chat: live streaming markdown,
per-turn activity strips, sources footer, IME composition fix
(community PR youssofal#119 by @penta2himajin). Startup ready in ~2s with
silent background warmup. All profiles run stock PyPI MLX; the
vestigial fork metadata is gone (youssofal#129).

Full notes: docs/releases/v2.0.0.md and CHANGELOG.md.
The v2 turbo default now covers every dense catalog model on every
Apple Silicon generation:

- 27B Optimized-Speed-FP16 (the M1/M2 routing target) defaults to
  turbo: 19-31% faster decode than the 2.0.0 default across 0.5k-32k
  context, true-AR multiplier 1.34x to ~2x, exactness gated on the
  real weights.
- New 6-bit affine verify kernels (split-K hexpack family): the 6-bit
  9B tier gains 33-62% decode and 43% 2k-prefill under turbo. Qwen 3.5
  9B and 9B FP16 now default to turbo.
- New model: Qwen3.6-27B-MTPLX-Optimized-Quality-FP16, the missing
  M1/M2 quality artifact, published and wired into the app picker, CLI
  catalog, and chip-aware routing (a Quality pick on M1/M2 resolves
  the FP16 sibling, like the speed lane). Includes the reusable
  fp16-sibling converter script.
- Load-time kernel self-validation: every turbo lane checks itself
  against stock MLX in the model's exact dtype/quantization at boot; a
  mismatching lane falls back to the stock path for the session and
  the verdicts surface as kernel_selfcheck in /health. Worst case on
  unusual silicon is 2.0.0 speed, never wrong output.
- MTPLX_FORCE_GPU_FAMILY_FALLBACK=1 rehearses the exact M1-M4 code
  path on newer machines; new kernel-matrix CI workflow runs kernel
  exactness plus a live turbo smoke on real M1 runners.
- Honest exclusions: 35B A3B MoE and Gemma 4 keep sustained (their
  architectures bypass these kernels); the 4B keeps sustained (turbo
  measured slightly slower at matched depth); compiled verify stays
  off for 6-bit models.

Full pillar regression against 2.0.0 (decode 0.5k-128k, prefill, warm
TTFT, peak memory) flat or better on every unchanged model.
… tag split (youssofal#149)

The streaming reasoning splitter held back only 7 trailing bytes (sized
to </think>) while the alias set recognizes tags up to </reasoning>; a
longer tag split across a streaming chunk boundary leaked the reasoning
block and raw markup into user-visible content. Hold back the full
16-byte tag window, mirroring the disabled-splitter path.
…#148)

The quickstart onboarding screens printed hardcoded
http://127.0.0.1:8000 Web UI and dashboard URLs regardless of
--host/--port. Thread host and port through the onboarding flow and
render wildcard binds with the shared bind-vs-connect resolver.
Multi-turn agent sessions now render reasoning history on Qwen's
trained contract, which fixes the plan-execution repetition marathons
reported on the 27B models:

- Scoped reasoning history (default on Qwen 3.6/3.5 templates): the
  chat template's own rolling checkpoint governs multi-turn rendering.
  Completed turns render without think scaffolds; the active round
  keeps its full reasoning, now including the structured
  reasoning_content agent clients send (previously dropped). The
  captured looping session goes from 3/4 repetition marathons to 4/4
  immediate healthy tool calls. --preserve-thinking on/off/scoped pins
  the behavior explicitly; templates without the rolling checkpoint
  (Gemma 4, custom) are untouched, and explicit on/off keeps cache
  identity byte-for-byte.
- Root-cause receipts: the loop reproduces on full bf16 weights under
  the old history rendering and vanishes on clean context at every
  precision level — context construction, not quantization damage and
  not the model alone.
- Serve on any host and port from the app (youssofal#109): wildcard binds
  resolve to connectable addresses, the port preflight tests the
  address family the daemon will bind, an API-key mismatch reads as
  live-but-unauthorized instead of lost, and LAN serving surfaces its
  API-key requirement before launch.
- Warm prefix reuse for every agent client (youssofal#138): the block-prefix
  restore lane engages for all clients under boundary-true restores,
  not just OpenCode's tool contract.
- Settings Off means Off (youssofal#140): the app passes the SSD session cache
  mode explicitly, including off, so session-bank stops growing back.
  The runtime venv self-heals after app updates (youssofal#139). Ctrl-C returns
  the terminal within a bounded drain under open SSE streams (youssofal#124).
- Opt-in Loop Guard (MTPLX_LOOP_GUARD=1) for repetition-damaged
  third-party quants: loop-armed steering that is bit-exact until a
  real verbatim loop is detected and never touches tool-call content.
  Default OFF — MTPLX does not alter sampling unless asked.

Community fixes in this release: streaming reasoning-tag leak fix
(youssofal#149, Osamaali313) and quickstart host/port rendering (youssofal#148, Takeshi
HASEGAWA).
davidtai and others added 24 commits August 2, 2026 19:08
The Hermes CLI writes base_url: '' for provider profiles without a
custom endpoint (for example openai-codex). The routing parser rejected
the quoted empty scalar, failed the whole model block, and marked the
profile unavailable — disabling it in the picker and hiding its
sessions. A quoted empty string is now a valid empty scalar and an
empty base_url is treated the same as an omitted one. Regression test
pins the openai-codex profile shape.
Qwen3.8-27B day-one prep. The vLLM reference contract reads the MTP layer
count generically (N stacked full-attention draft layers); MTPLX's tensor
gate and draft forward were frozen at the depth-1 template. A checkpoint
declaring mtp_num_hidden_layers > 1 died at load with
invalid-mtp-tensor-layout, and _mtp_core only ever ran mtp.layers[0].

- constants.expand_mtp_layer_keys(): every named expected-key set stays the
  canonical depth-1 template; expansion replicates the per-layer keys across
  the declared count (identity at N=1, so depth-1 behavior is byte-identical).
- artifacts._mtp_expected_key_set() + onboarding._expected_embedded_mtp_keys()
  + mtp_patch._mtp_contract_for_weight_keys() now expand by the config's
  declared layer count (numbered-expert MoE path was already N-aware).
- _mtp_core runs all draft layers in sequence, one KV cache per layer
  (make_mtp_cache was already per-layer); cache-length mismatch fails loud
  instead of silently truncating.

Tests: tests/test_mtp_depth_n.py — expansion identity at N=1, N=3
replication, tensor gate pass/fail at N=2, prequantized contract detection
at N=2, and a live two-layer inject + mtp_forward on a tiny real qwen3_5
TextModel with donor-harvested weights (lockstep cache offsets pinned).
Regression: test_mtp_patch/test_artifacts/test_onboarding/test_forge_cli
237 passed, 0 failed (exit 0, captured log).
…oussofal#175

Scratch-venv proof (2026-08-02): transformers 5.14.1 + mlx-lm 0.31.3 —
import clean (the 5.13.0 AutoTokenizer.register crash class does not fire),
real Optimized-Speed tokenizer encode/decode round-trip, and tool-bearing
chat template fingerprints byte-identical to the 5.8.0 baseline.
Field A/B in youssofal#227 (M5 Pro, 48GB): under bursty agent load the SoC soaks
past 90C during a burst, the smart lease restores auto after its 2s idle
debounce, and Apple's auto curve (~5,000 rpm) never drains the soak in the
gaps — so every following turn runs throttled (-51% decode, recovering only
after 100s of forced max fans).

Fix: after the idle debounce, the worker now probes the die temperature
(new soc_temperature_c(): hottest TC* sensor from ThermalForge status JSON,
MTPLX_SMART_FAN_SOAK_SENSOR to pin a key) and holds max fans until the die
cools below MTPLX_SMART_FAN_SOAK_RELEASE_C (default 75C), re-probing every
5s. Fails open: no readable die temperature -> legacy instant restore; and
MTPLX_SMART_FAN_SOAK_HOLD_CAP_S (default 180s) bounds the pin regardless of
sensor state so an idle machine always gets its fans back (the fans-left-
pinned lesson). Explicit synchronous restores (wait_for_restore, restore_now,
detach) bypass the hold. Hold state is surfaced in status()/health
(soak_holding, soak_last_temp_c, soak_release_reason).

Tests: 9 new (hold-until-cooled, hold-cap bound, probe-failure fallback,
env disable, bench-lane bypass, sensor selection/sentinel/pinning/absence);
thermal suite 46 passed, 0 failed, ruff clean. Live thermal A/B replicating
the issue's arms is deferred to the next controlled-fans benchmark session
per tonight's no-benchmarks constraint.
…l (PR youssofal#209 review)

Two review edits on the merged PR: (1) an explicit opt-in via
MTPLX_LINEAR_GDN_TAPE_IMPL=headquarter no longer silently degrades to the
incumbent when the kernel module fails to import — narrowed to ImportError
with a one-time warning; (2) the bit-exactness test clears the env var so a
stray headquarter setting in the invoking shell cannot turn the reference
arm into headquarter-vs-headquarter and pass vacuously.
…nes (o-LoRA routes, adaptive width, wide-M3, attention island) — davidtai
…, portability, test hygiene

Four required edits on the merged PR:

1. runtime.py: canonical_mixed_route now binds only on the explicit
   MTPLX_DSV4_O_LORA=gather_qmm opt-in. The default "cached" load takes the
   per-module dense route (bit-identical on the canonical artifact per the
   PR's own test_cached_dequant_is_bit_identical) instead of hard-validating
   the exact DeepSeek-V4-Flash topology — which refused every non-canonical
   DSV4 MTP artifact (8-bit/bf16 user conversions, other group sizes) that
   loads fine on v2.4.2. Also restores lazy wo_a dequant on default loads
   (~2.7 GiB eager materialization avoided).
2. test_deepseek_v4_attention_island.py: module-level mx.set_default_device
   (the PR youssofal#216 landmine — leaks CPU process-wide at pytest collection)
   replaced with the autouse save/restore fixture from the PR's own o-LoRA
   test file.
3. Island bench pair parameterized like the adaptive-width pair
   (MTPLX_DSV4_PYTHON/BENCH_DIR/MODEL_PATH/PROMPT_FILE/QUALITY_PLIST*/
   EXPECTED_WIRED_LIMIT_MB env): no more /Users/davidtai venv, bench, model,
   LaunchAgent paths; wired-limit gate is operator-pinnable and 0-disable.
4. Frozen per-file SHA256 SOURCE_MANIFEST removed from the arms script and
   its live-tree assert loop from the bracket test — it duplicated the
   exact-commit + clean-worktree gates and broke on every legitimate commit
   to runtime.py/deepseek_v4.py (including this train's).

Plus the reviewer's hygiene suggestion: the published no-developer-absolute-
paths gate now covers all three DSV4 bench pairs, not just adaptive width.

Receipts: 179 DSV4 lane tests green (island module in its own pytest
process), ruff clean. K<=3 ruling verified respected by review; bf16 lanes
remain opt-in and unpromoted.
… lane, D1 fusion + async scheduling receipts (davidtai)
… portable scratchpads, hygiene

Four review edits on the merged port:

1. alt_prefill_forward: enabled-but-unwired prefill flags (P1-P4) now raise
   NotImplementedError instead of silently running stock — same anti-fake-win
   contract the decode lane already enforces, so an A/B can never measure a
   "win" against a no-op arm.
2. Seven docs bench scratchpads dropped their hardwired /Users/davidtai
   worktree/model/bench paths for repo-relative sys.path roots plus
   MTPLX_LAGUNA_MODEL_DIR / MTPLX_LAGUNA_BENCH_DIR env overrides.
3. Unused imports removed from laguna_alt_step (dataclasses.field, FULL,
   StepGeometry).
4. test_metal_kernel_matches_reference restores the default device in a
   try/finally like its CPU siblings instead of leaking the gpu pin into the
   rest of the session.

Receipts: 28/28 alt-lane + steel-attn tests green (digest-equality and
fail-loud contracts included), product files ruff-clean. Notes for the
maintainer: the +5.8% decode receipt is an unwired benchmark-lane result —
wiring D1+S1 into the reference lane is follow-up work; 15 of 19 ported
kernels are receipt-only (unreachable from product code) and currently ship
in the wheel — packaging policy call deferred.
…iptor

- HY_V3_MTP_DESCRIPTOR (backend_id hy_v3_mtp): official Tencent sampler
  defaults temp 0.9 / top_p 1.0 / top_k off (generation_config.json of
  tencent/Hy3), qwen3-style reasoning codec, pre_norm hidden, contract-gated.
  Registered in DESCRIPTORS_BY_BACKEND_ID so the public serve wrapper, the
  in-server defaults, and /health all resolve it (previously fell back to the
  Qwen 0.6/0.95/20 coding sampler).
- Server: _model_declared_sampler_defaults() — hy_v3 artifacts' own
  generation_config.json applied at parse time when flags don't override
  (covers direct python -m mtplx.server.openai launches too). Deliberately
  scoped to hy_v3: flipping Qwen/Gemma defaults from artifact metadata would
  change shipped behavior and needs its own A/B.
- Reasoning codecs: Hy3 renames chat control tokens with an :opensource
  suffix at the same ids (<think:opensource>...</think:opensource>). Close-tag
  regexes now tolerate suffixed spellings (open/control already did), and the
  streaming holdback window grows to cover the longest suffixed spelling —
  16 chars could split </think:opensource> across emits, permanently missing
  the close and classifying the whole tail as reasoning.
- tests: un-skip the hy_v3 suite (the vendored class makes it runnable on
  released mlx-lm) + descriptor/sampler/split/stream regression tests. 7/7;
  reasoning stream suites 22/22.

(cherry picked from commit 7885e882ad06e9af6a926e3fcecabcbde618156b)
(cherry picked from commit 2c02204fc8d2af5eea34ef03aedc0dc8b1bfcd25)
The first real HY3 OpenCode run exited after printing <tool_calls:opensource> as visible text and created no files. MTPLX only understood Qwen-style tool envelopes, while the official HY3 tokenizer emits suffixed tool-call, separator, argument-key, and argument-value tokens.

Add a schema-aware suffix-token parser and streaming adapter, preserve JSON-shaped string arguments, validate emitted calls at the OpenAI boundary, suppress native control markup, and cover character-split parallel calls. The native app now launches HY3 with its tokenizer profile in both the command and environment instead of carrying the Qwen profile label.

Verified with the complete tool-stream translator, oMLX bridge, and server test modules plus a focused Swift command-builder test. This is a local checkpoint before rebuilding the experimental bundle and repeating the same OpenCode project.

(cherry picked from commit dbab059483cc4b8d8eda13e0864ccd00b8dbf82a)
…v3 at load

No released mlx-lm ships a hy_v3 class (ml-explore/mlx-lm#1211 open; the MTP
surface only exists in the #1485 stack). The 2.1.0 backend was therefore inert:
is_hy_v3_mtp_config dispatched, but mlx_lm.utils.load raised on the missing
module for every real checkpoint.

- mtplx/vendored_hy_v3.py: the #1211+#1485 reference implementation (kernelpool
  + eauchs lineage, as shipped by ox-ox), imports made absolute. Keeps and uses
  the MTP layer (MTPBlock + predict_next_tokens + return_hidden_states) instead
  of stripping it.
- install_hy_v3_model_shim(): registers the vendored module as
  mlx_lm.models.hy_v3 before mlx_lm.utils.load resolves the model type.
  Prefers a future upstream module IF it exposes predict_next_tokens; a
  base-only upstream (which strips MTP in sanitize) is overridden.
- runtime.load: install the shim for any hy_v3 config (with or without head),
  next to the qwen3_5_mtp trunk-shim precedent.

Verified: synthetic hy_v3 (real 120832 tokenizer, mixed-quant recipe shapes,
depth-1 MTP) loads through mtplx.runtime.load, AR forward + return_hidden +
mtp_forward + make_cache/make_mtp_cache all pass; generate_ar and
generate_mtp1 produce tokens with verify_calls/bonus/correction counters live.
tests: test_hy_v3_mtp_backend.py + test_artifacts.py 76/76.

(cherry picked from commit 0fc4bfe7a66ee7247d4737fd103b87f2f37fcbc3)
(cherry picked from commit a7908fc2a93f13cb0c653d11bf5683872682902a)
…res class

Fire-drill finding (Qwen3.8 day-one prep): a checkpoint with a fresh
model_type string but a known schema — the exact Qwen3.6 precedent
(shipped as model_type qwen3_5) that Qwen3.8 is expected to repeat — passed
`mtplx inspect` as verified (detection matches the architectures string)
but hard-failed at load, because mlx_lm.utils.load resolves the model class
from model_type alone: "Model type X not supported".

Fix: before trunk load, when model_type has no mlx-lm module but the
config's own `architectures` names a class in the verified table
(Qwen3_5[Moe]{ForConditionalGeneration,ForCausalLM,TextForCausalLM}),
register a loud sys.modules alias to the implementing module — the same
mechanism transformers uses for class resolution, and the same shim
precedent as qwen3_5_mtp/hy_v3. Unknown model_type + unknown architecture
keeps the fail-loud behavior.

Receipt: renamed-config fire drill (4B Speed clone, model_type
qwen3_8_drill) now loads through the alias, injects the MTP head, and
generates in MTP mode (depth 3) end to end via the public `mtplx run` path
— previously ValueError at load. 5 new unit tests pin the contract
(alias/fail-loud/native-untouched/text_config-nesting/idempotence).
… the parser default

Real-QA finding: `mtplx start --dry-run` advertised "profile: sustained /
mode: Sustained MTP" for the 27B Optimized-Speed flagship even though the
actual launch resolves turbo via _apply_model_default_profile — the
2026-07-16 stale-display bug class on one more surface (pre-existing on
v2.4.2, reproduced on the shipped build). Benchmarkers reading the dry-run
would pin the slow profile. The payload now uses
_resolved_default_profile_name(args, model); explicit --profile flags are
respected unchanged.

Receipts: dry-run flagship now prints turbo, --profile sustained still
prints sustained, test_onboarding + test_public_cli suites green.
…faces

2026-08-03 parity audit findings, all three real drifts fixed:

1. CLI OpenCode lane gains the four MTPLX_VLLM_METAL_PAGED_GQA_SDPA_* keys
   (long-context decode route) the app and the CLI hermes lane already set.
2. App codingAgentRuntimeEnvironment gains MTPLX_LAZY_TARGET_DISTRIBUTIONS=1
   (was CLI-only).
3. CLI `start pi` now composes exactly like the app: the shared coding-agent
   block (session bank, SDPA route, postcommit wait, frontier flags, tool
   prompt, template profile) + the Pi history-budget overrides. Previously a
   CLI Pi user ran a bare engine with none of that, and three history values
   (96/16/150) diverged from the app-lane numbers (72/8/120) every app Pi
   user already runs — unified to the app values.
4. The 35B Speed FP16 sibling gets the measured launch defaults (depth 1,
   target_prefix, draft 0.6/0.95/20) on the CLI exact-id gate too; the app's
   substring detection already applied them.

New tests/test_app_cli_env_parity.py parses MTPLXCommandBuilder.swift (the
test_model_catalog.py approach) and asserts key- and value-parity for the
shared block and the Pi composition, so this drift class now fails in CI.

Receipts: parity tests 2/2, public_cli+onboarding+model_catalog suites
green, ruff clean, swift build clean. Deliberate non-changes: hermes block
left duplicated (in-sync today); legacy 27B turbo one-sidedness and the
vestigial MTPLX_DISABLE_FAST_MLX_AUTODISCOVERY key documented for follow-up.
…ies; repair near-miss tool argument keys (youssofal#196, youssofal#197)

Two root causes from the youssofal#196/youssofal#197 reports, both proven by red->green unit
tests (no engine, sampler, or wire-protocol surface touched).

1. youssofal#196 hard error "malformed tool_call: unterminated stream" is MTPLX's own
   stream hidden-tool guard (STREAM_HIDDEN_TOOL_GUARD_TOKENS=2048 / _S=30),
   not a serializer bug. The guard stands down while the Qwen-XML stream
   parser is inside a <parameter=> value (tool_argument_in_progress), but the
   youssofal#170 JSON-dialect function body
   (<function=write>{"filePath": ..., "content": "..."}) waits in the
   find_parameter stage, so in_known_tool_parameter stayed False for the
   entire body. Any write payload >= 2048 hidden tokens streamed over >= 30 s
   (every multi-KB code/HTML file on slower hardware; the reporter runs the
   FP16 27B on an M2 Max 32GB) crossed the budget and generation was
   CANCELLED mid-call with the 422 - the exact reported symptom including the
   size correlation ("small prose writes succeed; code writes fail") and the
   exact error string. Fix: in_known_tool_parameter now also covers the
   JSON-dialect body wait state (known tool + find_parameter stage +
   object-body buffer), so the guard stands down over argument payload by
   construction - the same rule the loop-guard incident established (guards
   must treat tool-call payload spans as legitimate, not threshold-tuned).
   Unknown-tool bodies stay guarded.

2. youssofal#197 corrupted argument keys (offsets for offset; "offset "; "offset >")
   pass schema validation whenever the real parameter is optional (OpenCode's
   read tool declares offset/limit optional), so the argument was silently
   dropped client-side -> 13+ repeated no-op reads, context blowup. New
   _repair_tool_argument_keys_for_schema in the normalize path (shared by the
   streaming parser, the suffixed parser, and the final parser) renames a key
   only on an unambiguous mapping: not itself a schema property; resolves via
   trim (whitespace / trailing '>'), letter case, or a single trailing 's' to
   exactly one schema property; target not already supplied; no two keys
   collapsing onto one target. Anything ambiguous passes through verbatim
   (unknown-tool pass-through stays the client's contract). Repair runs
   before value decoding, so a repaired key also gets its schema-typed value.

Receipts: tests/test_tool_call_hidden_guard_and_key_repair.py (8 new tests)
mirrors the reporters' exact corruption shapes. Pre-fix behavior reproduced
on this tree before the change: tool_argument_in_progress=False mid-JSON-body
and {"offsets": 45} emitted verbatim; post-fix True / {"offset": 45}. Suites
green: 102 passed (tool/stream translator suites incl. the new file),
359 passed (test_server_openai.py + test_openai_bridge.py).

The remaining youssofal#196 layer (rare engine-side early stop mid-reasoning,
agent-context-only) stays open pending a captured failing turn via the 2.4.2
request log + MTPLX_REQUEST_CAPTURE_DIR replay; this commit removes the
self-inflicted mid-call cancellation lane from that investigation.
…alized MTP head

Full-suite gate caught a lineage collision between tonight's vendored-class
cherry-pick and the PR youssofal#208 graft lane already on main: the vendored model
constructs a native MTPBlock unconditionally, so inject_hy_v3_mtp_support's
native branch trusted it and an AR-only checkpoint (no draft tensors on
disk) returned mtp_enabled=True with a RANDOM head — silent acceptance
collapse instead of the clear AR-only error the graft tests pin.

Fix: the native branch now verifies the checkpoint actually carries draft
tensors (appended model.layers.{N}.* or model.mtp.* — key-name scan via
index json / safetensors headers, no tensor materialization) and raises the
same clear AR-only error otherwise. test_quantized_overrides_are_honored is
scoped explicitly to the graft lane it pins (drops the constructed native
block first), since the vendored class's real flow loads+quantizes via
mlx_lm.load_model.

Receipts: hy3 backend + graft pair 15/15 green together (previously 2
failed under cross-module shim registration); the 4 ruff E402s in the graft
file pre-exist on the shipped tree (importorskip pattern).
Promote the 2026-08-03 integration train as a capability release rather than a patch. Version every canonical package surface at 2.5.0 and add user-facing notes covering multi-layer MTP and architecture-alias readiness, first-class HY3 support, and the coding-agent bridge fixes proven through real OpenCode, Pi, Hermes, and protocol QA.

Credit David Tai explicitly for the DeepSeek V4, Laguna S-2.1, and GDN contributions while preserving his original commits and authorship. Keep each performance lane opt-in and document the measured DeepSeek speed, memory envelope, and agent-quality caveat honestly so the release advances capability without changing established defaults.

The release gate remains the rollback boundary: separate-install Qwen V2 A/B showed no consistent decode, prefill, or memory regression; the full Python suite and Swift suite are green before packaging.
@youssofal

Copy link
Copy Markdown
Owner

Review queued for the next cycle. The isolated sidecar (hermes -p <profile> serve --isolated) is the right containment; the gateway RPC auth path is the part I want to walk through carefully.

@youssofal youssofal left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is titled as an app feature but ships an engine policy change: _session_keep_live_refs_for_request hard-returns False for any Hermes client. 2.8.0's session-cache fix routes oversized snapshots through the live-ref lease that this disables, so merging as is reinstates the ~38k committed-frontier freeze for exactly the client class in #86. CI also never ran on the head. Split the app feature from the engine change and the app half can go in.

@PhilipJohnBasile

Copy link
Copy Markdown
Contributor

Superseded by #373.

The replacement is rebuilt from current main, keeps the diff app-only, and removes the engine/session-cache policy rejected here. It includes authenticated Hermes sidecar readiness, lifecycle guards, credential redaction, and externally active session handling.

Validation: 145 focused Hermes tests, 774 full Swift tests with 1 existing skip, and swift build passed.

@youssofal
youssofal force-pushed the main branch 2 times, most recently from 2382dfd to 8bc4d88 Compare September 1, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.