Repository navigation
Conversation
… sampling (cherry picked from commit 1d0d938)
Preserve record framing structurally so messages containing internal blank lines are not split into pseudo-records. - agent/context_compressor.py: * Extract _serialize_records_for_summary() to retain turn boundaries as a sequence of records. * Add _sample_summary_records() to sample across serialized turn records directly without lossy double-newline splitting. * Correct separator accounting in omission markers at gap boundaries so separator counts match full serialized text. * Update _sample_summary_input() to support structural records while preserving flat-string fallback with trailing fragment pruning. * Wire _generate_summary() to use _serialize_records_for_summary() and _sample_summary_records() in lean mode. - tests/agent/test_context_compressor.py: * Test multi-paragraph serialized messages maintain role boundaries and keep newest message paragraphs together. * Test newest message ending in blank lines does not produce empty tail anchor. * Test exact omission marker separator accounting. * Test end-to-end _generate_summary prompt assembly with multi-paragraph messages. (cherry picked from commit f68f1bf)
The record split added .rstrip("\n") to every serialized record. That
changes the bytes _serialize_for_summary returns for legacy tail_mode
and for agent/micro_compaction.py's _micro_summarize_one, which never
sample and never needed it: a message with trailing newlines was no
longer reproduced verbatim in the summarizer prompt. Sampling receives
records structurally, so an empty pseudo-record can no longer become the
tail anchor and the strip has no remaining purpose.
_sample_summary_input accepted either a flat string or a record
sequence and, for strings, rebuilt records with content.split("\n\n").
The PR's own third commit established that "\n\n" is not a record
delimiter (message bodies contain blank lines), so that fallback would
re-introduce split records for any caller that reached it. The only
production caller already passes records to _sample_summary_records;
drop the dual-signature wrapper and move the test callers to records.
Replace the eight sampler tests with two invariants. The boundary test now drives _generate_summary so it fails on main for the actual defect (a retained section starting mid-record) instead of on a helper name, and folds in the multi-paragraph case since blank lines inside a message were what broke the string-split approach. Dropped: covers_multiple_ regions and generate_summary_lean_samples_structural_records (green on main, no teeth), omission_markers_account_separators_exactly (asserted only that a marker exists), newest_message_ending_in_blank_lines (moot once records are structural), and preserves_oversized_last_record (a record above the whole cap is unreachable through _serialize_for_summary because _CONTENT_MAX bounds message bodies). The remaining oversized test asserts behaviour — tail kept, oversized record bounded — not the marker wording.
…ch#118390) Lean sampling is the documented budget mechanism, but nothing reported how much of a region actually reached the summarizer, which is the gap the reporter narrowed NousResearch#118362 to. _sample_summary_records now returns record-level coverage counters alongside the bounded transcript and _generate_summary writes them into the active compression telemetry (summary_input_chars / sampled_chars / omitted_chars / record_count / sampled_record_count / elided_record_count), so the existing compression_attempt JSON log line carries them with no transcript content. Char counters cover record content only, not separators or markers. Elision markers also name the one-based record range they stand for, so a reader can tell how many turns a gap hides, not only how many characters. Co-authored-by: KoNit-K <konit.block@protonmail.com>
Delete the `if len(res) > limit` re-trim at the end of `_bound_oversized_record` (agent/context_compressor.py:3488-3489). Why it can never fire: `marker_reserve` is the marker width formatted with `elided=len(record)`, the largest value `elided` can take, so the real marker is <= marker_reserve; `head` is `record[:head_len].rstrip` (<= head_len) and `tail` is `record[-tail_len:].lstrip` (<= tail_len); head_len + tail_len == limit - marker_reserve. Hence len(head + marker + tail) <= limit always. The branch was untested and unreachable (gate 2c W2).
…mary_records Delete the post-render overflow-trim loop (agent/context_compressor.py :3591-3607) and state the invariant in its place. Why it is unreachable: every display record is pre-bounded to `target` via _bound_oversized_record; each slice's greedy fill stops before `size + sep + next > target`, so a slice renders to <= target chars and the n slices to <= n*target (a merge of overlapping/adjacent slices is their union: overlap only removes chars, adjacency adds one 2-char separator but drops one marker of >= 2 chars). There are at most n-1 markers, each <= marker_len because marker_len is formatted with first=last=len(records) and elided=total_len, the maxima. Since target = (cap - (n-1)*marker_len) // n, the rendered length is <= n*target + (n-1)*marker_len <= cap. Gate mutation M5 (loop disabled) left all 7 sampler tests green; 30 random shapes never entered it.
… records The whole-record greedy fill in `_sample_summary_records` (agent/context_compressor.py:3541,3555-3563) stops a slice as soon as the next record would cross `target` (~20K), so each slice can be short by up to one record. With mid-sized records that wastes a large share of the 160K cap: probe worst case (10K records) filled 90,660/160,000 (57%), 8K records 129,021 (81%); main filled 159,988 by splitting records. Add a bounded extension pass after `selected` is built: newest slice grows backward, older slices grow forward, one whole record at a time, only while the exact rendered length stays <= cap and the slice does not run into its neighbour (adjacent slices are merged so no separator is lost). Records are never split and the cap is never exceeded. After: worst random shape 150,581 (94%, remaining headroom < one 10K record), 8K records 153,117 (96%), 1551-record region 159,996 with 968 whole records, 0 partial, 8 buckets within +-10%, telemetry input_chars == sampled_chars + omitted_chars (probe: $D/fold/probe_C_fold.py). The existing keeps_record_boundaries fixture (1.2K records) fills 99.1% without this pass, so no fill assert was added there — it would be toothless.
`_begin_compression_telemetry` (agent/context_compressor.py:1947-1960) did not seed the six `summary_input_*` counters that `_record_summary_input_coverage` adds on the lean path, so the emitted `compression_attempt` line (conversation_compression.py:1451-1470) had a different key set for lean and legacy attempts. Seed them as None so the schema is stable; probe: lean and legacy attempts now emit identical key sets (gate 2c suggestion, L1947-1960).
…y_records The coverage docstring (agent/context_compressor.py:3510-3511) said the counters count "record content only", but `sampled_chars` sums the *display* records (post `_bound_oversized_record` truncation) while `input_chars` sums the raw records. Spell that out so telemetry consumers do not compute `omitted` two different ways (gate 2c suggestion, L3512-3513/3524-3525).
tests/agent/test_lean_single_aux_call.py:163 accepted either `<seg09>` or a `content[-500:]` substring. The last slice is anchored to the newest record, so the fallback can never be what passes; assert `"<seg09>" in out` outright and drop the now-unused `content`. Red when the tail anchor is defeated (end = len(records) - 1): 1 failed; green at head (gate 2c suggestion).
The budget-extension pass in _sample_summary_records grew the newest
slice until it hit the cap and only then moved to older slices, so on
mid-size records all headroom went to one region: 10K x 100 records ->
records/slice [1,1,1,1,1,1,1,8] (newest 4.3x the mean). That contradicts
the design comment above (regions must not consume each other's budget)
and the docs' "evenly sampled". Wrap the per-slice loop in a
`while grew` round so each slice adds at most ONE whole record per
round (newest grows backward, others forward); the cap pre-check and
exact re-render check are unchanged.
Before -> after (records per slice, fill unchanged):
10000x100 [1,1,1,1,1,1,1,8] -> [1,2,2,2,2,2,2,2] fill 0.944
8000x100 [2,2,2,2,2,2,2,5] -> [2,2,2,2,2,3,3,3] fill 0.957
4000x300 [4,4,4,4,4,4,4,11] -> [4,5,5,5,5,5,5,5] fill 0.987
2000x600 [9,9,9,9,9,9,9,15] -> [9,9,10,10,10,10,10,10] fill 0.995
500x2000 [37,...,37,39] -> [37,...,37,38,38] fill 0.998
Re-gate probe (40 random shapes): 0 violations, worst fill 0.892 -> 0.930,
worst char drift 4.27 -> 1.83; under-cap output byte-identical to base.
Test: test_lean_sampling_oversized_middle_record_does_not_evict_tail now
asserts no slice holds more than mean+1 records (RED with the previous
newest-first order: [16,16,16,16,16,16,18]; GREEN: [16,16,16,16,16,17,17]).
Finding: $D/regate/C.md W1 (agent/context_compressor.py:3599-3623).
…check The comment claimed "marker widths shrink as records leave a gap" — but shrinkage can only make the render smaller, which the pre-check already tolerates. The exact re-render exists for the opposite case: a gap's first index can gain a digit or thousands separator (999 -> 1,000, +2 chars) when the added record moves a marker, and that growth can push a render sitting at cap over it. Comment only; no behaviour change. Finding: $D/regate/C.md suggestion 1 (agent/context_compressor.py:3619).
No fixture exercised the final `selected = _merged(selected)` in _sample_summary_records: the re-gate mutant that dropped it survived all 8 bounding tests and 40 random probe shapes. Add 20 x 8.5K records (170K > 160K cap, headroom > one 8.5K gap) so the round-robin extension closes the gaps between the newer slices. Assert consecutive shown records are joined by exactly "\n\n" (an unmerged adjacent pair renders as "...xxx[USER]: record-..." with no separator), remaining gaps carry a correctly numbered marker, fewer than 7 markers survive, and sampled_record_count equals the whole records shown. RED with the final `_merged` call removed: AssertionError (14, 15); GREEN at head. Finding: $D/regate/C.md suggestion 2 (M-c, agent/context_compressor.py:3623).
…of inline The slice-build loop in _sample_summary_records hand-rolled the same "overlapping/touching slice -> extend previous" fold that the `_merged` closure 25 lines below implements (agent/context_compressor.py:3563-3568 vs :3590-3597). Hoist `_merged` above the loop, append raw (start, end) pairs, and merge once before the extension pass; the first `_render` no longer wraps `selected` in a redundant `_merged` since it is already merged. One interval-merge implementation instead of two. Output is byte-identical: the merge is applied once to the same ordered slice list, so the extension pass starts from the same `selected`. Verified with a 47-shape capture (40 probe_C2 shapes + 7 edge shapes) diffed before/after: identical sampled text and coverage; probe_C2 40 shapes -> 0 violations; under-cap base identity x4 True.
…ing _render The `if cursor < len(records)` block at the end of `_render` (agent/context_compressor.py:3580-3583) duplicated the gap-marker arithmetic of the in-loop branch but had no path to it. Invariant: `_render` is only reached with non-empty `records` (empty returns early), and n >= 1, so the slice-build loop always runs its last iteration, which anchors `end = len(records)` and `start = end - 1 >= 0`, hence `end > start` and the final slice ends at `len(records)`. `_merged` keeps `max(end)` for the last interval, and the extension pass only grows the newest slice backward (`(s - 1, e)`) or older slices forward but never past the next slice's start, so the last slice's end stays `len(records)`. Therefore `cursor == len(records)` after the loop for every input. Byte-identical across the 47-shape capture (40 probe_C2 shapes + 7 edge shapes incl. single-record and empty); probe_C2 -> 0 violations; under-cap base identity x4 True.
`_bound_oversized_record` (agent/context_compressor.py:3485-3488) split `remaining` with float arithmetic (`int(remaining * 0.5)`) and guarded the tail slice with `if tail_len else ""`. `remaining = limit - marker_reserve` is >= 1 because the line above already returned when `limit <= marker_reserve`, so `head_len = remaining // 2 <= remaining - 1` and `tail_len = remaining - head_len >= 1` always: the guard can never fire. Use `remaining // 2` and drop the dead guard. Byte-identical: `int(r * 0.5) == r // 2` holds for every non-negative int (checked exhaustively for r in 0..10**6), and the guard was dead. Capture of 800 bounded-record cases (4 record shapes x remaining 1..200) plus the 47-shape sampling capture is identical before/after.
…T_SLICES
tests/agent/test_context_compressor.py:3397 asserted
`sampled.count("chars elided") < 7`, hardcoding the n-1 gap count that
the 8-slice constant implies and that the comment explained in prose.
Use `ContextCompressor._SAMPLED_INPUT_SLICES - 1` so the invariant
("the extension pass closed at least one initial gap") survives a
change to the slice count instead of silently becoming a change
detector. `_SAMPLED_INPUT_SLICES - 1 == 7` today, so the assertion
value is unchanged.
…mary_records `_finish` (agent/context_compressor.py:3586-3588) only wrapped `_coverage` and re-derived `shown` from the final `selected`; it was called once, at the return. With `_merged` hoisted above the slice loop the classmethod now has two closures (`_merged`, `_render`) plus `_coverage` instead of four. Same expressions, same evaluation order (`_render` first, then the coverage counters), so the output and coverage dict are byte-identical — verified on the 47-shape capture and probe_C2 (0 violations, base identity x4 True).
(cherry picked from commit 5a87336)
The invariant (source .caf untouched, sibling .wav not overwritten, owned output removed) belongs beside the existing CAF tests rather than in a new file. The conversion-error case exercised the same ExitStack return-inside-with path as provider-error, so it is dropped to stay at two cases. Also reverts the unrelated encoding="utf-8" drive-by on the .env fixture in TestTranscribeCredentialReadGuard.
Pass ignore_cleanup_errors=True to the TemporaryDirectory that holds the converted CAF->WAV file (tools/transcription_tools.py:420). WHY: the sibling trim-dir cleanup a few lines below already swallows errors (shutil.rmtree(..., ignore_errors=True)), but the CAF work dir did not. On Windows an AV scanner or the search indexer briefly holding the freshly written WAV makes rmtree raise out of TemporaryDirectory.__exit__, turning an otherwise successful transcription into an exception. The kwarg is available since 3.10; pyproject requires-python >= 3.11, and plugins/teams_pipeline/meetings.py:199 already uses it. No new test: the behaviour is stdlib.
Remove `monkeypatch.setattr(audio.shutil, "which", lambda _name: None)`
from test_caf_conversion_preserves_neighbors_and_removes_owned_output
(tests/tools/test_transcription_tools.py:1360).
Why it is dead: `_convert_caf_to_wav` (tools/transcription_audio.py:168-177)
tries candidates in order ffmpeg-then-afconvert and returns on the first
success. The test already patches `_find_ffmpeg_binary -> "ffmpeg"` and
`_run_quiet -> encode`, which never raises, so the ffmpeg candidate always
succeeds and the afconvert candidate is never executed. Whether
`shutil.which("afconvert")` returns a path or None cannot change the
outcome. The patch also mutated the process-global `shutil.which` for the
test's duration for no benefit.
Proof: `-k Caf` selection is 4 passed before and after.
(cherry picked from commit e88e7cb)
…dable The new read-failure branch in gc_blobs() returned (0, 0) silently, so `hermes curator ledger --compact` printed "0 unreferenced blob(s) removed" as if the sweep had run. Log a warning with the underlying error so an operator can tell "nothing to reap" from "could not look". The kept invariant test now also asserts the warning via caplog.
compact_ledger() caught OSError on the read but decoded the bytes outside the guard, so a ledger with invalid UTF-8 raised UnicodeDecodeError out of `hermes curator ledger --compact`. Same escape class the gc_blobs() fix closes; treat it identically (no-op, ledger left untouched).
Three read_text() calls in test_entry_stores_only_changed_paths_and_rollback_still_restores gained encoding="utf-8" alongside the gc_blobs fix. They are unrelated Windows hygiene (the footgun checker does not flag them); keep the PR scoped to the blob-GC read-failure bug.
gc_blobs() aborts the sweep on a json.JSONDecodeError (tools/skill_ledger.py:351-352) but did so silently, while the sibling read-failure abort a few lines above logs "ledger unreadable (...); blob GC skipped". `hermes curator ledger --compact` therefore printed "0 blobs removed" as if the sweep had run and found nothing. Emit the same warning family so the operator learns the ledger needs repair before blobs can be GC'd. Test: `malformed-json` param on the kept invariant test — (0, 0), blobs intact, and "blob GC skipped" in caplog. RED with the warning removed (assert 'blob GC skipped' in ''), GREEN at head.
After json.loads() succeeds, gc_blobs() called row.get(...) unconditionally (tools/skill_ledger.py:353). A syntactically valid but non-object line such as `[]` or `"x"` raised AttributeError out of gc_blobs() and up through `hermes curator ledger --compact`. list_entries() already tolerates such rows with an isinstance(row, dict) check (~L414); mirror it here and abort the sweep with the same "malformed ledger line; blob GC skipped" warning — a row we cannot interpret may still hold blob references, so nothing may be deleted. Test: `non-dict-row` param on the kept invariant test (ledger + b"[]\n"). RED with the guard removed (AttributeError: 'list' object has no attribute 'get'), GREEN at head.
…trim comments and tests `_cap_binds` was the last inline copy of the cap-clamped-to-window predicate that NousResearch#119406 centralised; the banner still prints the raw configured cap. Plugin engines without the helper report "not binding", as before when no cap was set. The threshold_tokens comment and the new test module stop narrating the incident; the parametrize takes the whole agent config so the `{}` failure path is spelled out rather than derived from a ternary.
…NousResearch#119456) * feat: setup profile is minted by the backend and found by role, not by name The guided onboarding runs in a profile the desktop used to create itself (profiles.create with a soul, "already exists" treated as success) and recognise by the literal "hermes-setup". The upcoming setup toolset grants catalog installs to that profile, so the marker that grants it must be written only by the backend. - profile.yaml carries `role: setup`; read_profile_meta / write_profile_meta / ProfileInfo know it; profiles.list and GET /api/profiles report it. - hermes_cli/setup_profile.py: ensure (find by role, adopt a pre-role hermes-setup dir, else clone default + soul + role) and reset (soul, memories, skills back to the created state, in place). The soul text moves here from the renderer. - tui_gateway/methods_onboarding.py: onboarding.ensure_setup_profile and onboarding.reset_setup_profile. Neither takes a name; profiles.create and profiles.configure already reject `role` (unknown key, 4000). - Copies never inherit the role: --clone-all, profile import, and a distribution that ships profile.yaml drop it. - setup.status for a named profile reports `ready` once the boot bootstrap settled. Since one host backend serves every profile (NousResearch#118246) the desktop's setup-profile probe lands on this branch, which never set `ready`, and the kickoff waited forever. - Desktop: SETUP_PROFILE, ensureSetupProfile(profiles.create) and composeSetupSoul are gone. store/setup-profile.ts holds the name the backend returned (or the roster's role row after a relaunch); kickoff, handoff and the build card use it. The dev reset calls the reset RPC. * fix: write the setup soul as bytes so Windows keeps \n line endings * refactor(desktop): drop the renderer's setup-profile store; the backend is the only owner Kickoff reads the name straight from onboarding.ensure_setup_profile and records it on $setupSession, which every later step already carries. The handoff recovery check reads the roster row's role. No renderer module holds a setup-profile name or a fallback lookup.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…Luna show up (NousResearch#119412) The ChatGPT Codex models endpoint gates entries on client_version against each model's minimal_client_version. Hermes sent the "0.0.0" sentinel, which used to return the whole account catalog; since the GPT-6 Sol/Luna rollout it returns a frozen legacy list (astra + the 5.6 trio) while any version at or above the newest minimal_client_version (0.155.0, 1.0.0, 99.0.0 alike, live 2026-09-22) returns everything the account is entitled to. So the picker and the context-window probe hid gpt-6-sol / gpt-6-luna that the vendor's own client showed for the same account. Both request sites now go through fetch_codex_catalog_entries(): try the newest-client URL first and fall back to the "0.0.0" sentinel only when that answer is non-200 or empty (the backend used to reject out-of-sequence versions that way). No local ~/.codex/models_cache.json is consulted: a missing or stale cache (this host records 0.147.0, which hides gpt-6-sol at 0.155.0) must not decide what the account can see. Report by @Stone441 (NousResearch#119412); supersedes NousResearch#119420 by @KoNit-K, whose fix read the compatibility version from the local `~/.codex` cache.
An empty catalog now legitimately triggers the 0.0.0 sentinel fallback (two requests per site), so the fake answers one model and the one-request-per-site count still holds; every request still carries the residency headers.
…othing else can grant it (NousResearch#119491) The setup toolset (empty until NS-964 registers request_catalog_install) is reserved for the profile whose backend-written profile.yaml carries role: setup. Two points enforce it: - Grant: tui_gateway/server.py::_load_enabled_toolsets folds the in-scope profile's role toolsets into all three return paths (configured CLI toolsets, coding posture, HERMES_TUI_TOOLSETS pin), next to the client-surface set. The pin keeps it too: an operator pin picks configurable toolsets and must not strip the profile's own. - Deny: model_tools._select_tool_names strips toolsets reserved for any other role from every selection, including None/"all", a saved platform_toolsets list, profiles.configure and the env pin. That is the one point every surface's selection passes, so a default session cannot get the tool by any route. The role is read with read_profile_meta on get_hermes_home(), which under a session's home override is that session's profile dir (no directory scan). setup stays out of _HERMES_CORE_TOOLS, CONFIGURABLE_TOOLSETS and the platform-native recovery loop (it has no tools, so the loop skips it). Linear NS-963.
… salvage NousResearch#107451) (NousResearch#119536) * fix(profiles): toggle MCP servers via the enabled key the runtime reads The profile editor (profiles.describe / profiles.configure) tracked an MCP server's on/off state through a `disabled` key on `mcp_servers.<name>`, but every runtime resolver keys off `enabled` (enabled_mcp_server_names, coding_context, oneshot, the gateway's tool-name resolver). Disabling a server in the editor wrote `disabled: true` and the server stayed live at runtime; a server with `enabled: false` in config showed as enabled in the editor. describe now reports `enabled` (default true) and still honours a legacy `disabled: true`. _save_mcp_toggles writes `enabled: true/false` for every server and drops the legacy `disabled` key, so old configs migrate on the next save. Tests pin describe's reading of `enabled`, `enabled: false`, legacy `disabled: true` and an unset flag; configure's written `enabled` flags and the removal of `disabled`; and that a server disabled through the editor is excluded by enabled_mcp_server_names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(profiles): trim MCP toggle tests to two invariants The describe-only and configure-only tests are subsumed by the round trip: configure writes the key the runtime resolver reads, and describe agrees with that resolver. * fix(config): migrate legacy MCP `disabled: true` to `enabled: false` Older profile editors switched an MCP server off by writing `disabled: true`, a key no runtime reader consults, so those servers kept running while the editor showed them off. The editor now writes `enabled`, but configs saved before the fix still carry the old key until the user saves again. Config v46 converts each truthy `disabled` to `enabled: false` and drops the key; a falsy `disabled` is inert and left alone. `hermes update` runs it for every profile, so the runtime turns off what the user already turned off. The runtime readers stay on one key; they do not learn `disabled`. `disabled: true` wins over an explicit `enabled: true`: `hermes mcp add` writes `enabled: true`, and the old editor only added `disabled`, so the pair on disk means "the user turned this off in the editor". --------- Co-authored-by: BowmanStephen <34071312+BowmanStephen@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
) The `enabled` key had four parsers. The MCP client (`_parse_boolish`) read `enabled: 0` as on; the toolset resolver and editor (`_parse_enabled_flag`) read it as off. The server list (`summarize_server`, `/api/mcp/servers`) read any non-`False` value as on, so `enabled: "false"` showed on while the agent skipped it. The catalog and `hermes mcp list` accepted only true/1/yes, so `enabled: on` showed off while the server ran. `tools/mcp_tool_common.py::mcp_server_enabled` is now the only reader, and every surface calls it. `_parse_boolish` treats YAML numbers by truthiness (0 off, other numbers on). Everything else keeps the client's semantics: the off words are off, absent / null / junk stay on, with the existing warning for junk. The desktop MCP page mirrors the rule in `serverEnabled` (`apps/desktop/src/lib/mcp-servers.ts`). One case table (`mcp-enabled-cases.json`) drives the Python invariant test and the vitest test, so the page and the runtime cannot drift apart again.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…rmes tools picker, desktop connector card, health sweep) (NousResearch#119601) * fix(mcp): ACP, `hermes tools` and the desktop connector card use the one enabled reader NousResearch#119567 left four readers of `mcp_servers.<name>.enabled` on their own rules. @webtecnica's NousResearch#119560 found the ACP one: - `acp_adapter/session.py::_make_agent` used `is not False`, so an ACP session kept a server with `enabled: "false"` or `enabled: 0` in its toolset while the MCP client skipped it. - `hermes tools` MCP picker (`tools_config_mcp._configure_mcp_tools_interactive`) read `enabled: 0` as on and crashed on a non-dict entry. - Desktop connector card (`connectors/data/join.ts`) and the MCP health sweep (`store/mcp-health.ts`) used `enabled !== false`. All four now call `mcp_server_enabled` (Python) or `serverEnabled` (renderer), which share one case table. Co-authored-by: webtecnica <webtecnica@gmail.com> * chore: retrigger CI (zero-job dispatch failure, auto-heal) --------- Co-authored-by: webtecnica <webtecnica@gmail.com>
Avoid removing Chromium's live collapsed selection container while sweeping zero-length composer text-node litter.\n\nCloses NousResearch#100302. (cherry picked from commit 9dc30fa)
The NousResearch#100302 sweep guard keeps the caret's own empty text node alive, but normalizeComposerEditorDom has three more paths that can delete the node a live caret is anchored in: the phantom tail-block removal, the trailing-br removal, and the block unwrap. Per the DOM spec a boundary re-anchors in the removed node's parent, so the caret reads as "valid" while its position has drifted (the NousResearch#88621 class: editor stays activeElement, printable keys stop producing input events, clicking restores typing). Normalization is text-preserving, so this snapshots the caret once — both as the boundary (node, offset) and as a composerPlainText offset, the same units the undo/redo caret save already uses — and re-establishes it via placeCaretAtOffset only when the snapshotted anchor node no longer exists in the editor. Hidden keep-alive composers are excluded: the snapshot is null unless the selection is a single collapsed range inside this editor, so a background repaint never steals the document selection the visible composer owns. Skipped when the editor does not hold focus (document.activeElement). The sweep guard from the previous commit stays: skipping the node is cheaper than a remove-then-restore round-trip and keeps the caret node identity stable through the empty-litter case. Tests: the new invariant (caret restored when its anchor block is removed) fails on the base commit without this change; a second test pins that a still-valid selection passes through untouched. Composer suite 461/461, tsc/eslint/prettier clean. (cherry picked from commit 741265a)
…modal (NousResearch#119633) * feat(desktop): catalog install card rows for plugin and skill targets manage_catalog opens the same connection operation manage_connections does, with rows of kind plugin/skill. The renderer collapsed every non-connector kind to 'mcp', so those rows would have drawn as MCP servers with no name, purpose or provenance. - Contract: ConnectionTargetKind gains plugin/skill; ConnectionOperationTarget types the catalog fields agreed in CATALOG-ROW-CONTRACT.md (display, description, tier, platforms, repo, sha, subdir, scan, requirements, has_desktop_half, target_profile, app_state, skill). - Store: kinds map through a lookup; catalog rows carry a typed CatalogEntry. - CatalogRow: kind glyph, name, kind/tier/platform pills, one-line purpose, then Install / Advanced / Skip. Installing shows the backend's per-row detail; installed shows "N tools · show names"; failed shows the reason and Try again (approved again for that row only); skipped stays quiet. - Advanced: a centred dialog with the Plugins-tab controls (halves, target profile, enable, force, pin) plus read-only source and scan. Its Install sends the values in the answer env; the row's Install sends env null. - A missed tool.start restores the card on a manage_catalog row. * fix(desktop): catalog row uses the app's sliding progress and one pill style The installing row drew a full-width pulsing line, which reads as a finished rule, not work in progress; it now uses the Progress primitive's sliding indeterminate block (reduced motion honoured there). The kind pill was all-caps monospace next to two sentence-case pills; all three now share one shape, matching the V1 offer artboard, and the platform pill stays the one coloured note. The Advanced modal's Source and Security tables share a fixed label column, and requirements render as prose, not code. * fix(desktop): a skill's Advanced shows only the controls a skill has A skill has no agent/desktop halves, no enable step and no commit pin, so its Advanced dialog offered three controls that do nothing. It now shows the target profile, force reinstall, source, security and credentials, and sends only target_profile, force and the credential values. The preparing line takes the card's width instead of the whole chat column.
…chat, no Connect-now (NousResearch#119644) Installing a plugin from any surface now makes its MCP servers and skills usable in every open chat of that profile on the chat's next turn. There is no Connect-now button, no /reload-mcp, no relaunch. - hermes_cli/plugins_activation.py::load_and_go_live: after the forced plugin rescan, connect the plugin's portable MCP servers one by one (register_mcp_servers, the connector flow's call), then refresh that profile's open chats and queue a turn note listing the servers, their tools and the plugin's skills. activation gains live_now; deferred keeps only Python tools and prompt sections. - MCP tools are deferred behind tool_search / tool_call, so appending them (preserve_prefix) leaves the model-facing tool array and the cached prompt prefix unchanged; the note rides the existing one-shot turn-note channel, so the system prompt stays byte-stable. /reload-mcp keeps its consent gate: it is a full rebuild. - tui_gateway/methods_tools.py: one session walk (_refresh_live_sessions) shared by reload.mcp and plugin activation, filtered to the plugin's profile home. - hermes plugins install/enable (another process) asks the running Desktop / dashboard backend to do the in-process half through the new POST /api/dashboard/agent-plugins/activate, found via the host rendezvous record and its session token. - Desktop: the Connect-now toast and its strings are removed in all six locales; the install toast says what went live ("3 tools connected · skill X ready") and warns per server that did not connect. - Contract: PluginActivation.live_now; regenerated TS/OpenRPC. - register_skill docstring: plugin skills are listed by skills_list.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…emise
A worker that verifies its card's work already landed on main had no honest
verb: complete_task demanded evidence of work it did not do, block_task
demanded a blocker that did not exist. So it exited rc=0, the dispatcher
booked a protocol violation, retried 3x, and gave up with a crash-flavoured
notification. Measured 9 wasted runs across t_e484ec2d + t_e587758d.
- kanban_complete(superseded_by=<card|PR|sha>) closes the card done with
run outcome 'superseded' and the pointer as its evidence; no summary /
result / survivor required. An EMPTY pointer is refused
(EmptyCompletionError) — an unnamed supersede is a silent delete of work.
- SUCCESS_RUN_OUTCOMES: every reader that asks "did this succeed" now counts
'superseded' (parent handoff context, role history, respawn guard, crash
streak, edit backfill), or a superseded card reads as never-run.
- Dispatcher: two byte-identical clean exits are a REPRODUCED no-op, not a
flake. Stop at 2 instead of spending the full budget on the same run;
event carries stopped_early=reproduced_clean_exit + identical_violations.
Absent worker output never compares equal, so the early trip stays off.
- Notifier: "premise superseded by X" and "needs input: finished with
NOTHING TO DO Nx identically (no crash)" instead of "gave up".
- Worker protocol (KANBAN_GUIDANCE 4b) + stop-nudge + retry corrective text
tell workers to use it instead of exiting silently. The violation constant
got SHORTER (349 -> 298 chars): its 500-char budget is shared with the
worker's own explanation, which is the load-bearing half.
Verified: tests/hermes_cli/test_kanban_superseded_outcome.py 10 passed
(kernel, dispatcher early-trip, notifier wording); tests/tools 4 new passed;
CLI e2e against a sandbox board — empty pointer refused, real pointer ->
status=done outcome=superseded meta={'superseded_by': ...}.
Regression: tests/hermes_cli -k kanban = 21 failed / 457 passed; the same 21
fail at origin/main in a clean-room worktree (inherited, +10 passing).
tests/gateway+tools+agent -k "kanban or prompt" = 2 failed / 394 passed;
both also fail at origin/main. ruff clean.
Collaborator
Author
|
Closing: branched from origin/main (NousResearch upstream, split kanban modules) but this repo's main is the ANG-Ventures fork with a monolithic hermes_cli/kanban_db.py — unrelated histories, so the diff cannot apply. Re-porting onto fork/main; will open a replacement PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the platform gap measured on 2026-09-22: 9 wasted runs across
t_e484ec2d+t_e587758d.The gap
A worker that verifies its card's work already landed on main has no honest verb.
complete_taskdemands evidence of work it did not do;block_taskdemands a blocker that does not exist. So it exits rc=0, the dispatcher books:...retries 3x (the retry reproduces it identically), then
gave_upwith a notification that reads like a crash. Three identical clean exits were never going to become a fourth different one.What this adds
kanban_complete(superseded_by=<card|PR|sha>)- closes the carddonewith run outcomesupersededand the pointer as its evidence. Nosummary/result/survivor required. An empty pointer is refused (EmptyCompletionError): a superseded card with no pointer is a silent delete of work. Also on the CLI as--superseded-by.SUCCESS_RUN_OUTCOMES- every reader that asks "did this task succeed" now countssupersededalongsidecompleted: parent-handoff context, role history, the respawn guard, the crash-streak breaker, the edit backfill. Without this a superseded card reads as never-run and gets respawned.stopped_early=reproduced_clean_exit+identical_violationson the event. Absent worker output never compares equal, so the early trip stays off when there is no evidence.KANBAN_GUIDANCEstep 4b, the turn-end stop-nudge, and the retry corrective text.Constraints from the card honoured: no new
HERMES_*env var; the survivor escape is not widened (superseded simply has no artifact to preserve);supersededcannot skip the evidence pointer.Verification
RED-first.
tests/hermes_cli/test_kanban_superseded_outcome.py(new, 10 tests) failed on current main before the change:The acceptance test closes a card whose premise is already satisfied and asserts it ends after ONE run,
done/superseded,superseded_bypopulated,consecutive_failures == 0, with a distinguishable notification. The dispatcher test asserts the reproduced-no-op path holds after two runs and never spawns a third.CLI end-to-end against a sandbox board:
Regression, two-arm control (the 21/2 failures below are INHERITED - the identical lists fail in a clean-room worktree at
origin/main):tests/hermes_cli -k kanbantests/gateway+tools+agent -k "kanban or prompt_builder or system_prompt"Delta is +10 / +4 passing = the new tests.
ruffclean.One regression WAS caught and fixed mid-run: lengthening
_PROTOCOL_VIOLATION_ERRORpushed the worker's own last output past the 500-char cap_record_task_failurestores, reddening two existing reap tests. The constant is now shorter than main (349 -> 298 chars) - that budget is shared with the worker's explanation, which is the half the board actually needs.Fixture cards
Both live fixtures moved while this was in flight and I did not retro-close either:
t_e484ec2dreacheddoneon its own merits (run 7271, PR #892 approved), andt_e587758dhas a live run. Their value here was the measurement, which the new tests now encode as a permanent gate.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.