Commit Graph

13249 Commits

Author SHA1 Message Date
Teknium a2e19d484c refactor(prompt): memory/skills guidance — one builder, positive posture, session-accurate wording (537 → 255 served, −282/call) (#97760)
* refactor(prompt): diet the memory/skills guidance block — schema-taught curricula removed, form rule + pruning contract kept (537 -> 223 tok in the combined block)

* polish: literal check-glyphs in source; memory capacity posture — save proactively, replace/consolidate when full

* refactor: single spine for memory/profile guidance — form rule + capacity posture written once, variants differ only in opening frame

* refactor: ONE memory-guidance builder — frame adapts to enabled stores, body written once, positive posture leads (maintainer direction)

* fix wording: memory is loaded per SESSION, not injected per turn (maintainer correction)
2026-08-29 02:30:44 -07:00
Kshitij Kapoor 2abf72ad97 fix(gateway): register hygiene_max_turn_hold_seconds + flat retry-after on turn-hold abandonment
Review follow-ups on the salvaged #90845:

- hygiene_max_turn_hold_seconds registered in config_defaults next to
  its sibling hygiene knobs (run.py already read it; the key was
  undiscoverable).
- Turn-hold abandonment now records a flat 60s retry-after via the
  existing cooldown column. Without it, sustained traffic re-spawned,
  held, and cancelled a fresh compressor on EVERY turn — a per-turn
  summary-model token burn that never commits. Deliberately outside the
  x1/x3/x9 failure ladder: the compressor is healthy, so the failure
  streak must not advance (witness updated to assert exactly that
  boundary: no streak increment, flat <=120s spacing, turn-hold reason).
2026-08-29 13:02:13 +05:30
Machan-Army 31b974dfb7 fix(gateway): split turn-hold expiry from idle-timeout failure path
Introduce HygieneTurnHoldExceeded exception so turn-hold budget expiry
no longer collapses into the generic asyncio.TimeoutError handler.

- Add HygieneTurnHoldExceeded exception (availability boundary, not a failure)
- Add dedicated handler: stamps AGENT_COMPRESSION_TURNHOLD provenance,
  sends deferral notice, does NOT increment failure cooldown
- Preserve #87011 contract: idle timeout still sends 'no output' message
  and takes failure path
- Add behavior witnesses: turn-hold ≠ idle timeout, cooldown untouched

Fixes semantic boundary collapse flagged in PR #90845 review.
2026-08-29 13:02:13 +05:30
Machan-Army 543c85acbf fix(gateway): bound the hygiene-compression turn-hold so a streaming summary cannot freeze the turn
Session hygiene auto-compression runs inline on the incoming-message path
and awaits the summary worker with a progress-aware inactivity budget
(hygiene_timeout_seconds) that extends up to hygiene_total_ceiling_seconds
(default 600s). A summary model that keeps streaming tokens keeps resetting
the inactivity slice, so the wait can stretch toward the ceiling while zero
bytes reach the user — chat transports (Telegram ~30s idle-timeout) drop the
connection and the turn appears frozen, even though the gateway is healthy.

Add hygiene_max_turn_hold_seconds (default 10), a turn-hold budget that caps
the wall-clock the incoming message waits on hygiene compression. The wait
slice is additionally capped at the remaining budget so the budget is
re-evaluated even when the worker keeps the inactivity slice large. On
exceeding the budget the gateway abandons the inline wait and proceeds on
the uncompressed transcript via the existing timeout path, which revokes the
worker's commit admission (CompressionCommitFence) and defers cleanup — so a
stale compression finishing later can never overwrite the turns appended
after the wait was abandoned.

Well under the typical transport idle-timeout, this guarantees the message
is answered promptly while the detached compression completes in the
background. Configurable via compression.hygiene_max_turn_hold_seconds.

Adds a regression test: a worker that streams progress continuously (so the
inactivity slice never fires) must be abandoned once it exceeds the
turn-hold budget, the turn proceeds uncompressed, and the stale commit is
fenced (no session mutation, role alternation intact).
2026-08-29 13:02:13 +05:30
Jakub Wolniewicz 23bae43cfa fix(agent): normalize list-shaped streaming content deltas 2026-08-29 12:45:43 +05:30
kshitijk4poor c03c72a19e refactor(vertex): fold _creds_cache_key into _sa_snapshot (simplify pass)
Final /simplify-code pass on the full diff: after the content-digest
rework, _creds_cache_key had become a wrapper with zero production
callers — get_vertex_credentials inlined the same read/except dance —
kept alive only by its own tests. One helper now owns the
(bytes, cache_key) resolution for all three cases (ADC sentinel,
readable file, unreadable fallback); prod calls it, tests target it.
No behavior change: 10/10 green, same mutation-check results.
2026-08-29 12:33:44 +05:30
kshitijk4poor a377b33ffb test: replace the source-scanning call-site test with a behavior-level planner test
test_every_marker_emitting_call_site_goes_through_the_central_clamp read
production .py files with a regex and pinned an exact call-site count --
the change-detector / reads-source-in-tests antipattern AGENTS.md bans
outright. Replaced with a test that drives the real
plan_cache_sections_for_destination fan-in and asserts the emitted wire
markers: ttl=1h on the measured opencode-go route, ttl stripped on the
unmeasured opencode route. Mutation-checked: emptying
MEASURED_1H_PROVIDERS turns it red.
2026-08-29 12:23:41 +05:30
Jack Powrie d51d66e869 fix(cache): preserve the configured 1h TTL on the OpenCode Go route
`effective_cache_ttl` evaluates the generic `is_qwen_model` clamp before any
route-level allowance, so a configured `prompt_caching.cache_ttl: 1h` is
silently rewritten to `5m` for every Qwen model on opencode-go. Reproduced on
this commit's parent, no provider traffic:

    effective_cache_ttl('1h', 'opencode-go', 'qwen3.7-plus')  -> '5m'
    _build_marker('5m')                                       -> {'type': 'ephemeral'}

A repair for this exists historically -- payload d6b33faae1, merged as
a43fe4918d -- but neither commit is reachable from current main
(`git merge-base --is-ancestor` returns non-zero for both), so the regression
is live on this lineage.

Restores the precedence fix: MEASURED_1H_PROVIDERS (an allow-list holding only
opencode-go, the one route measured with a delayed read past five minutes) is
consulted ahead of the generic Qwen clamp, with NO_1H_TIER_MODELS nested inside
it for models measured to ignore the tier even on a capable route.

opencode-go stays in ALIBABA_FAMILY_PROVIDERS. That set is also the
cache-marker-layout opt-in read by
agent_runtime_helpers.anthropic_prompt_cache_policy, so narrowing it would turn
working five-minute caching into *no* caching rather than extending the window.
The two sets are kept separate on purpose and a test pins the separation.

One deliberate divergence from the historical payload, found by independent
review: that patch checked NO_1H_TIER_MODELS globally, ahead of the provider
gate. MiniMax on its own Anthropic-compatible endpoint is a separate and
genuinely cache-eligible route, and the global check regressed its configured
1h to 5m off the back of an opencode-go observation -- an unrelated-provider
change this repair must not make. Measured:

    base      effective_cache_ttl('1h', 'minimax', 'MiniMax-M2.5') -> '1h'
    payload                                                        -> '5m'
    here                                                           -> '1h'

Scope note on the evidence: the delayed-read run covered qwen3.8-max and
glm-5.2. The rule is keyed on the route, not the model, so the deployed
qwen3.7-plus is covered by it but has never itself been measured. This change
restores *sending* the requested 1h marker; it does not establish that the
provider honours 1h retention. Those stay separate claims, and the provider
labels every write `ephemeral_5m_input_tokens` whatever ttl was requested, so
only a delayed read past five minutes with no intervening call can settle it.

Tests: 11 new cases covering the deployed model, marker shape, precedence
against the generic clamp, cache eligibility surviving the repair, negative
controls for unrelated routes, provider case normalization, and a closed-set
audit of every marker-emitting call site. Ten mutations were applied to scratch
copies and each turned the suite red, including hoisting the generic clamp back
above the allowance, dropping opencode-go from ALIBABA_FAMILY_PROVIDERS,
un-nesting the model denial, and adding a new unclamped sender.

Known risk, not discharged here: this marker shape has never been sent on the
Go relay. #77217 records the sibling Zen relay returning HTTP 400 on an
unexpected marker shape, so field validation must check HTTP status before it
looks at any cache counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QV9Ld4f5ZTqdZWncpfZcQh
2026-08-29 12:23:41 +05:30
kshitijk4poor 913b910434 fix(vertex): content-digest cache key — stat signature is not credential identity
Security review on #97701 (unsupportedpastels) found and reproduced a
metadata-signature collision: atomic replacement that preserves size
and mtime (deployment tools that restore metadata; equal-length JSON)
rotates the private key under an IDENTICAL (path, mtime_ns, size)
signature, so the cache kept serving the old identity — exactly the
failure the PR set out to close.

The stat idiom is right for config caches (guards a parse, mtime
collisions are harmless). It is wrong for a credential cache (guards
an identity). The key is now (path, sha256(content)):

- _read_sa_file() reads the file ONCE per call, returning both the
  bytes and the digest key; on a miss the credentials are built from
  that same snapshot via from_service_account_info — closing the
  stat->read TOCTOU the reviewer also flagged (key and credentials can
  never describe different bytes).
- Read failure degrades to the bare-path key and the SDK's own file
  read: byte-for-byte the pre-signature behavior.
- Cost: one read + sha256 of a ~2KB JSON per cache probe, noise next
  to the OAuth token mint the cache exists to avoid.

New regression test per the review: equal-length content swap with
mtime restored and atomic replace — asserts a NEW cache key and a NEW
Credentials object (fails on the stat-keyed version, reproducing the
reviewer's cache_keys_equal=True). Fake google-auth harness gains
from_service_account_info.

Suite 10/10; ruff green.
2026-08-29 12:21:49 +05:30
kshitijk4poor ef4cd77ffc fix(vertex): restore ADC->SA retry killed by tuple cache keys (review finding)
The /simplify-code reviewer caught a real regression in the signature-
keyed cache commit: the ADC-failure fallback still compared
`cache_key == "__adc__"` (string), but keys are tuples now — the
comparison is always False, silently disabling the retry that picks up
a service-account file added after startup. The lead's pre-verification
had checked sentinel collision, failure-path pop, and TOCTOU, but
missed this consumer of the OLD key shape.

Guard now tests the actual condition (`not resolved_path` — this
attempt was ADC) instead of a key literal, so it can't rot again if
the key shape changes. New regression test drives the full path: ADC
raises, SA file appears on re-resolution, retry succeeds — fails on
the tuple-comparison version AND on any future key-shape change that
breaks the guard.
2026-08-29 12:21:49 +05:30
kshitijk4poor 56a2623316 fix(vertex): pick up rotated service-account files — signature-keyed creds cache
Pattern-D fix (stale cache after out-of-band change): the Vertex
credentials cache was keyed on the service-account file PATH alone, so
rotating the file on disk (key revoked and re-issued, new identity)
kept serving tokens minted from the OLD Credentials object for the
life of the process. Operators rotate compromised keys precisely when
they most need the new identity to take effect.

The cache key is now the file's (path, mtime_ns, size) signature — the
established idiom (agent/skill_utils.py:414, hermes_cli/config.py:3343,
and the shape #89792 applies to model overrides). Rotation bumps the
signature, forcing one re-read; the superseded entry for the same path
is evicted on insert so the cache stays bounded at one Credentials per
file. ADC keeps a stable sentinel key ("__adc__",) and its existing
expiry/refresh handling; a stat failure degrades to the bare-path key,
i.e. exactly the pre-signature behavior.

Tests (tests/agent/test_vertex_adapter.py):
- rotation invalidates: rewrite + mtime bump -> new key, new
  Credentials object, old entry evicted (fails on main: main serves
  the first identity's object after rotation)
- stat failure falls back to bare-path key, never raises
- ADC sentinel stable across None/empty resolved paths

Suite 8/8 green; ruff green.
2026-08-29 12:21:49 +05:30
Teknium 1d8946b40b fix(prompt-caching): tool-using sessions no longer 400 behind LiteLLM Anthropic proxies (#89886)
LiteLLM OpenAI->Anthropic translation copies tool-message content parts
verbatim, so the envelope-layout part-level cache_control landed at
tool_result.content[0] - a placement the Anthropic Messages schema rejects
with a non-retryable HTTP 400 that killed the whole turn (any tool-using
cron/session on a LiteLLM-fronted Anthropic route).

New envelope_tool_part_cache_markers_supported() predicate (keyed on the
existing _is_litellm_route token matcher) threads a tool_part_markers flag
through build_prompt_cache_plan / apply_anthropic_cache_control and all
four decoration sites (main loop x2, destination replan, MoA). On LiteLLM
routes role:tool messages carry no markers and the breakpoint budget
reallocates to the nearest eligible message; OpenRouter/Nous Portal keep
the part-level form they honor, native Anthropic layout unchanged.
2026-08-29 12:06:48 +05:30
Clifford Garwood 1ff8ec9a49 test(skills): cover the patch recovery loop end to end
Asserting an error string contains "read" proves the wording, not that a
model obeying it reaches a working patch. TestPatchRecoveryLoop walks the
loop through the public tool — half-formed patch, read the error, follow it,
patch succeeds — and pins the invariants that centralizing validation could
have broken: ordering ahead of the skill lookup, new_string='' still
deleting, missing new_string still rejected, and a rejected patch leaving
the file byte-identical.

The two recovery tests fail against the pre-fix dispatcher; the rest pass
either way as regression guards.
2026-08-28 23:17:35 -07:00
Clifford Garwood 4f4e778db8 fix(skills): make skill_manage patch failures recoverable instead of a dead end
`skill_manage(action='patch')` rejected a missing `old_string` with:

    old_string is required for 'patch'. Provide the text to find.

That is a dead end. The model cannot tell whether it omitted the argument or
supplied text that did not match, so it retries blindly — and then escapes to
the neighbour that always works: `action='write_file'`, which rewrites the
entire skill file and destroys unrelated content. `skill_manage`'s own action
enum puts that destructive path one token away from the failing one.

The error now names the recovery route: `old_string` must be the EXACT text
currently in the file, read the target first (the skill's SKILL.md, or the
file named by `file_path`), copy the snippet verbatim, and do not fall back
to `action='write_file'`.

Validation lives in `_patch_skill` rather than the dispatcher. `skill_manage()`
previously returned its own bare missing-argument error before ever calling
the helper, which would leave the new guidance unreachable through the public
tool. Removing that duplicate makes the helper the single source of truth; its
{"success": False, "error": ...} flows through the same json.dumps path, so
the serialized shape is unchanged, and validation still precedes the skill
lookup — a missing old_string on an unknown skill still reports the argument
error rather than "skill not found".

Fixes #33064
2026-08-28 23:17:35 -07:00
Teknium 217ab2f8df refactor(desktop-tools): consolidate preview + project, diet the desktop_ui suite (3,861 → 2,293 tok/call, −41%) (#97659)
* refactor(desktop-tools): consolidate preview(open/close/read) + project(create/switch/list), diet the desktop_ui suite — 3,861 -> 2,293 tok/call on desktop sessions (-41%)

* rename: preview -> desktop_preview, project -> desktop_project — namespace desktop-app tools against MCP/plugin name collisions

* test: sync remaining old-name pins — per-file registration import, GUI_TOOLS set, post-hook case read_preview -> desktop_preview action=read
2026-08-28 23:10:01 -07:00
StanleyStetson 24e54b55f5 fix(desktop): preserve streamed assistant text and unify atomic persistence (#95514)
- Preserve streamed assistant text in Desktop UI when message.complete delivers empty text.

- Prevent destructive hydration in Desktop useMessageStream over rendered text on empty completion.

- Recover stream buffer in finalize_turn when final_response is empty on healthy turns.

- Unify in-place blank assistant repair, watermark clone resolution, non-blank concurrent winner adoption, and batch row appends into a single atomic guarded SessionDB transaction.

- Synchronize canonical committed content to live in-memory messages dicts and preserve all-or-nothing rollback semantics on persistence failure.
2026-08-29 11:33:05 +05:30
joaomarcos 6f689d0b05 test(cache): pin the prompt-cache scope isolation invariant for per-response session ids
Squash of the three commits on PR #96768 (net diff is tests-only: the
mid-series production hunk in agent/transports/codex.py was reverted
within the PR after review). Pins the cache-scope isolation invariant
for hosts that mint one physical session per response, plus the
system-prompt write-path lifecycle under a per-response session (#96570).
2026-08-29 11:33:05 +05:30
joaomarcos 88a78ecc96 fix(bedrock): recover from server-side cachePoint rejections per placement
Bedrock's cachePoint rules are per-model-family AND per-field. Amazon Nova
accepts a cachePoint block in `system` and `messages` but rejects it inside
`toolConfig.tools`, failing the whole request with

    ValidationException: Malformed input request: #/toolConfig/tools/18:
    extraneous key [cachePoint] is not permitted

so every tool-enabled Nova turn fails, with no retry path and no way for the
user to turn cache markers off (#97281).

The adapter decided placement from one static allowlist that answers only
"does this model cache at all", never "in which section". Any family whose
placement rules differ breaks 100% of turns until someone edits the table and
ships a release — the same maintenance trap the `_NON_TOOL_CALLING_PATTERNS`
comment already admits to ("if a model fails with a tool-related
ValidationException, add it here").

Make Bedrock's own verdict authoritative alongside the table: classify the
rejection by the JSON pointer AWS returns, drop the marker for that one
section, retry the request once, and remember the verdict for the rest of the
process so later turns are built clean. The other sections keep their cache
markers, so Nova still gets system/messages caching instead of losing prompt
caching wholesale. This mirrors the module's existing self-heal idiom
(`is_streaming_access_denied_error` → non-streaming `converse()`).

Applied at all four boto3 call sites: `call_converse`, `call_converse_stream`,
and both Bedrock dispatch sites in `chat_completion_helpers` (the streaming
one is the path in the report). A rejection with no marker to strip returns
None so the caller re-raises instead of looping.

Fixes #97281

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gcoy6nLTg5R6FHHhjcLZEC
2026-08-29 11:33:05 +05:30
Teknium 3951ead838 fix(cache): over-length caller prompt_cache_key no longer 400s Chat Completions requests
Port from anomalyco/opencode#44571: OpenAI caps prompt_cache_key at 64
chars (DeepSeek and Zai inherit the same limit via their OpenAI-compatible
APIs) and rejects longer values with HTTP 400. The Responses transport
already bounds keys via _bounded_prompt_cache_key, but the Chat
Completions transport passed caller-supplied keys (request_overrides,
top-level or extra_body) through unmodified on both the profile and
legacy kwargs paths.

Bound caller keys with the same pck_<sha256[:24]> hash shape codex.py
uses so both transports behave identically; blank keys are dropped
instead of sent empty. Hermes-generated keys were already safe
(content-addressed pck_ hashes).
2026-08-29 11:33:05 +05:30
ericmaddox f0d5f1298b fix(caching): prevent whitespace-only text blocks in prompt cache prefix splits 2026-08-29 11:33:05 +05:30
kshitijk4poor 7eee066c30 refactor: fold simplify-code review findings into #96667 salvage
- Anthropic classifier counts signature_delta and citations_delta payloads
  (content-bearing delta types the transport emits — relay_llm.py handles
  both) so signed-thinking/cited-text generation keeps ticking the fence.
- Fix the stale 'per streamed event' comment above the Anthropic
  on_stream_event lambda (left over from the conflict resolution).
- Rename test_completed_response_without_stream_payload_does_not_tick to
  test_completed_response_ticks_only_terminal_signals — the old name
  contradicted its own assertion (dispatch + shim ticks are expected).
2026-08-29 11:08:11 +05:30
kshitijk4poor 436435bfc9 test: pin fence/TTFP semantics for substantive-progress gating (#96667 salvage)
Follow-up for the salvaged substantive-progress fix, adapted to main's
three-hook architecture:

- Keep the dispatch tick and the completed-response shim tick (both
  deliberate on main; one-shot terminal events that cannot defeat an
  inactivity timeout) — update the two PR assertions accordingly.
- Pin the end-to-end bug: keepalive/empty-role chunks must leave
  CompressionCommitFence stale, a substantive token must refresh it.
- Pin the fast-lane telemetry contract (#96945/#96963):
  time_to_first_progress_ms records on the first frame of any kind via
  the split-out _notify_aux_timing_response helper.
2026-08-29 11:08:11 +05:30
StanleyStetson 7ff70f1709 fix(agent): count only substantive auxiliary progress
Salvages the stream-progress fix from #80122 on current main while incorporating review feedback for empty provider deltas and tool-call scaffolding.
2026-08-29 11:08:11 +05:30
kshitijk4poor 2f01ec9fa4 fix(teams): allowlist-gate BF attachment auth, stream downloads under media cap, lock token refresh
Second follow-up for salvaged PR #94547, folding in review findings from the
duplicate-PR cluster (#47015, #55054, #58476, #72977, #73685 all fix the
same 401) and the sweeper review of #73685:

- Replace the dot-anchored suffix predicate with exact-match against the
  existing _ALLOWED_TEAMS_SERVICE_HOSTS allowlist (two of the five
  duplicate PRs converged on this independently). Any Azure customer can
  register <name>.trafficmanager.net profiles, so suffix matching was not
  safe. Also requires https on the default port — :444 on an allowlisted
  host no longer receives the bearer (sweeper finding on #73685).
- Stream _fetch_attachment_bytes through _read_httpx_body_with_limit
  instead of buffering response.content — the shared inbound media cap now
  applies to authenticated downloads too (sweeper finding: a lying
  Content-Length must not OOM the gateway).
- Serialize token refresh with a lazily-bound asyncio.Lock so concurrent
  attachments share one STS POST (review finding on #94547).
- Token expiry now uses time.monotonic() (from #55054) — wall-clock jumps
  can't extend a stale token.
- Tests updated: exact-allowlist predicate (lookalike/subdomain/port/scheme
  negatives), streaming fake client, and a concurrent-cold-cache lock test.
  Mutation-checked: suffix match, silent drop, no-lock, and unbounded
  buffer each fail a test.
2026-08-29 11:04:38 +05:30
kshitijk4poor c23d40af17 fix(teams): dot-anchor Bot Framework host check, log dropped BF images, tests
Follow-up for salvaged PR #94547 (Sibbern's Bot Framework attachment auth):

- The host check used bare endswith('trafficmanager.net') /
  endswith('botframework.com'), which matched attacker lookalike hosts
  (evil-trafficmanager.net) — sending the bot's bearer token off-platform.
  Same threat model _ALLOWED_TEAMS_SERVICE_HOSTS already documents. Now a
  single dot-anchored predicate, _is_botframework_attachment_host(),
  used by both _fetch_attachment_bytes and the _on_message image branch
  (was copy-pasted in two places).
- BF images whose bytes fail image validation were silently dropped with
  no log (cache_media_bytes returns None; old path logged). Add the
  missing else-warning, mirroring the document branch.
- Record cached_m.media_type instead of the raw content_type so the
  MessageEvent MIME matches what was actually cached.
- Init _bf_token_cache in __init__ (was masked by getattr).
- Tests: host predicate dot-anchoring (attacker lookalikes blocked),
  BF routing vs generic helper, bearer attach + attacker-host block end
  to end, token acquire + cache reuse, token-failure degradation, and
  the silent-drop regression guard. Mutation-checked: reverting the
  dot-anchor, the else-warning, or the token cache each fails a test.
2026-08-29 11:04:38 +05:30
kshitijk4poor 112baae665 fix(state): close gate blind spots — alias + variable-SQL readers (simplify findings)
Reviewer findings from the formal /simplify-code pass, all verified:

1. Scanner blind spots (HIGH): five more locked pure readers were
   invisible to the v1 gate — get_compression_fallback_streak and
   get_compression_ineffective_count hid behind `conn = self._conn`
   aliasing; list_gateway_sessions, find_session_by_origin and
   search_sessions hid behind SQL held in variables/f-strings (the
   scanner required unknown == 0 to flag). All five converted to
   _read_ctx(); the scanner now (a) tracks self._conn aliases and
   (b) flags lock blocks with NO proven write instead of silently
   skipping unprovable SQL. Sabotage self-check extended to pin all
   three detection classes (literal, alias, variable-SQL) plus a
   mixed variable-SQL writer that must NOT fire.

2. get_meta reverted to the writer lock: its inline comment (present
   on main) documents a real read-your-writes dependency —
   fts_rebuild_step reads rebuild progress that a pooled WAL reader
   cannot see mid-transaction. The blanket conversion had overridden
   a documented design decision; it is now the single justified
   _ALLOWED_LOCKED_READERS entry, replacing the dead
   _enter_fts_fail_open entry (whose lock block counts 3 writes and
   never needed allowlisting).

Strengthened scanner on pre-conversion main: 43 violations
(39 pure-read + 4 no-proven-write). This branch: zero.

Suites: gate 2/2; tests/test_hermes_state.py + tests/state/ 331
passed (same 3 pre-existing FTS-rebuild reds as clean main); 433
passed across the 12 consumer suites of the five newly-converted
methods (compression anti-thrash, session search, status, scheduler).
2026-08-29 10:38:53 +05:30
kshitijk4poor 0534f1033b perf(state): route 39 pure-read SessionDB methods off the writer lock + gate
Pattern-C architectural fix (write-lock contention), completing what
#90734 started: that PR fixed the four UNLOCKED readers racing the
writer connection; this one fixes the 39 LOCKED pure readers convoying
every concurrent turn's persistence behind the global writer lock, and
adds the gate that stops the class from re-entering.

The gateway shares ONE SessionDB across every agent. A read-only query
under `with self._lock:` blocks all concurrent writers for its
duration; under WAL, _read_ctx() serves the same read from a pooled
read-only connection with no lock at all (non-WAL falls back to the
locked writer byte-for-byte, so DELETE-journal installs are unchanged).

Converted (SELECT-only bodies, mechanical `with self._lock:` →
`with self._read_ctx() as conn:`): gateway routing loaders, session/
message counters, titles, compression tip/lineage/cooldown readers,
telegram topic bindings, prune candidate scans, meta readers, resume
resolution — 39 methods, verified per-method that every statement is a
SELECT and every conn use stays inside the with-block. Read-modify-
write methods and checkpoint/maintenance PRAGMAs stay on the writer
lock (their read is ordered against their own write).

Gate: tests/state/test_no_locked_readers_gate.py — an AST scanner over
SessionDB that fails CI when a pure-read method body takes the writer
lock, with a sabotage self-check proving the scanner fires. On
pre-conversion main it reports 38 violations; on this branch, zero.

Measured (2 writer threads + 3 reader threads, 3s, WAL):
  before: 78.5k reads, p99 2.49ms, max 160.7ms  (readers convoy)
  after: 200.4k reads, p99 0.53ms, max  25.5ms  (2.6x throughput,
         4.7x better p99, 6x better tail; writes unchanged)
Honest caveat: on runtimes where WAL is refused (the currently-bundled
SQLite 3.46 trips the WAL-reset-vulnerability gate → journal=DELETE),
_read_ctx() falls back to the identical locked-writer path and this
change is behavior-neutral by construction; the win applies to WAL
installs (legacy WAL DBs, fixed runtimes, wal-configured operators).

Suites: tests/test_hermes_state.py 243 passed; combined state sweep
540 passed — the only 3 reds are the pre-existing
test_fts_runtime_rebuild failures, verified failing on clean
origin/main before this change.
2026-08-29 10:26:21 +05:30
Brooklyn Nicholson fac3c62334 feat(cli): render Ghostty-level pets in the interactive pane
Reuse kitty Unicode placeholders plus after_render write_raw so
prompt_toolkit's screen-diff can host the same crisp sprite as the TUI.
Re-queue the transmit after Ctrl+L / resize so the image comes back.

Co-authored-by: Sam Foreman <saforem2@gmail.com>
2026-08-28 23:38:59 -05:00
Brooklyn Nicholson d889758521 feat(pet): gate Unicode placeholders to kitty and Ghostty
WezTerm speaks kitty APC but not U+10EEEE, so detect_terminal_graphics()
== "kitty" is the wrong gate for the placeholder path.
2026-08-28 23:38:59 -05:00
Brooklyn Nicholson 5e550838f7 feat(kanban): board export/import REST endpoints
POST /boards/{slug}/export and POST /boards/import, so the desktop and
dashboard can drive board transfer. Both exchange filesystem paths
rather than bytes, the same contract profile export/import uses and for
the same reason: the client runs its native save/open dialog on the
machine that hosts the backend, so a path is all either side needs, and
a board carrying a few hundred megabytes of attachments never has to
cross the renderer heap.

Also covers the pre-existing rename (PATCH) and delete endpoints, which
had no tests — including that delete archives to a restorable directory
and refuses to touch `default`.
2026-08-28 22:38:01 -05:00
Brooklyn Nicholson 3150e444b2 feat(kanban): export and import a whole board as a portable archive
`hermes kanban boards export|import` moves a board between machines:
tasks, comments, links, history, and attachments in one .tar.gz.

Two things make this more than a tar of the board directory. The
database is live — kanban runs in WAL mode, so a filesystem copy loses
whatever still sits in the -wal sidecar and tears if the dispatcher
commits mid-copy; export goes through SQLite's online-backup API
instead. And rows carry machine-local state: claims, worker PIDs,
absolute workspace and attachment paths, session ids, and the gateway
chat ids subscribed to task events. Shipping those verbatim is how an
imported board arrives holding a claim owned by a process on someone
else's laptop, or starts pushing task events into a stranger's Telegram
thread. Everything machine-local is stripped on export and re-stripped
on import, since an archive is untrusted input.

Imports always land as a NEW board, auto-suffixing the slug on
collision, so an import can never merge into or overwrite a board that
is already there. Tasks whose workspace was a directory or git worktree
on the source machine are parked in triage rather than left for the
dispatcher to claim and burn into the failure breaker.
2026-08-28 22:38:01 -05:00
Gille 9a1eef7a29 fix(tools): narrow MCP OAuth lock scope 2026-08-28 20:16:26 -07:00
Teknium 3340bbbdad feat(a2a): client tools config-gated — disabled unless enabled (−561 tok/call on unconfigured installs) (#97421)
* feat(a2a): outbound client tools are config-gated — served only when a2a_agents configured, inbound platform enabled, or A2A_PORT set (-561 tok/call on unconfigured installs)

* ci: retrigger after runner startup_failure on rerun attempt
2026-08-28 15:08:08 -07:00
brooklyn! a73b14c438 Merge pull request #96726 from NousResearch/bb/bot-mode-design-system 2026-08-28 15:06:08 -05:00
Teknium dadbfd8990 refactor(patch): V4A mode gated to OpenAI-family mains — base schema is replace-only with real required (365 -> 195 for everyone else, -149; handler accepts both shapes from any model) (#97403) 2026-08-28 12:59:41 -07:00
Mohamed HAMMANE 6da0ae1cf5 fix(install): preserve project config for locked uv sync (#82446) 2026-08-28 12:36:15 -07:00
Teknium 15eb5caf7a fix(install): Node 24.0–24.10 no longer passes the gates only to die at npm EBADENGINE
The locked dependency tree now carries @babel/* 8.x, which requires
node ^22.18.0 || >=24.11.0. Our engines.node arm said ^24.0.0 and the
installer gates (node_satisfies_build / Test-NodeVersionOk) accepted any
Node 24 — so a system Node 24.0–24.10 cleared every gate we own and then
failed 'npm install' with EBADENGINE under engine-strict=true.

- Raise the 24 arm to ^24.11.0 in root + desktop package.json and the
  package-lock.json mirrors
- Tighten node_satisfies_build (install.sh) and Test-NodeVersionOk
  (install.ps1) to 24.11+; update user-facing wording
- Add invariant tests: every engines.node arm floor must satisfy every
  locked dependency's engines.node, and the installer gates must encode
  the same floors as the manifest — so the next babel-style floor bump
  turns into a CI red instead of a user install outage
- docs: correct stale 'Node.js v22' provisioning claim
2026-08-28 12:20:40 -07:00
Teknium 93de1d3430 vision_analyze diet + image routing: explicit aux vision backend becomes the de-facto route (reverses #29135) (#97339)
* refactor(vision_analyze): schema diet — routing mechanics removed (automatic; native path's own result teaches), region flow kept (~271 -> 181 tok/call, -33%)

* feat(image-routing): explicit auxiliary.vision backend is the de-facto image route — reverses #29135 (maintainer decision); native stays default when unset, image_input_mode:native stays absolute
2026-08-28 12:15:24 -07:00
Teknium 72874b0675 feat(skill_manage): operations[] is the call — each op names its skill; atomic with cross-skill rollback (#97295)
* feat(skill_manage): operations[] batch — several ops on one skill, atomic with rollback (memory-tool pattern); staged as ONE pending write under the approval gate

* refactor(skill_manage): operations[] IS the interface — single op = list of one (maintainer-directed); flat fields unadvertised handler compat; delete = sole-op routing

* guard(skill_manage): reject intra-batch same-file clobbers — double write/remove per path, full rewrite after an earlier SKILL.md edit; patch chains stay legal

* refactor(skill_manage): name-per-op — the call IS the operations array; cross-skill batches with all-touched-skills rollback

* guard(skill_manage): unify the intra-batch conflict guard — any destructive op on an already-touched file is rejected, with path normalization

Aggressive live testing found three holes in the two-part guard:
patch-then-write and patch-then-remove on the same supporting file
silently discarded the patch, and './references/x.md' //-style path
spellings slipped past the duplicate-write check. One rule now covers
the class: a destructive op (write_file/remove_file/full rewrite) on a
(skill, normalized-path) any earlier op touched is rejected pre-effect;
additive patches stay legal, so patch chains and write-then-patch still
work. Tests cover all three holes plus the pre-effect assertion.
2026-08-28 12:15:18 -07:00
69k4xmdfm2-blip fc5fdb8c2a fix(gateway): handoff is broken on multi-profile installs (wrong DB, wrong key, wrong bot)
`/handoff <platform>` never completes on a multiplexed gateway, and when it
does complete it can deliver through the wrong profile's bot. Three distinct
faults, all the same family: multi-profile code paths that assume a single
store / a single adapter map.

1. The watcher polls only the ROOT store.
   `_handoff_watcher` resolves `self._session_db` with no profile scope, which
   always yields the root `state.db`. But `/handoff` run under
   `hermes -p <profile>` writes `handoff_state='pending'` into THAT profile's
   store. Nothing ever reads it, so the CLI times out after 60s while the
   gateway is alive and connected. The watcher now iterates
   `[(None, None), *secondary_profiles]` and polls each inside
   `_profile_runtime_scope`.

2. The destination session key is built without the profile namespace.
   `_process_handoff` called `build_session_key()` with no `profile=`,
   producing `agent:main:...` while that profile's own adapter routes organic
   inbound messages on `agent:<profile>:...`. The handoff bound a key nobody
   reads.

3. Delivery uses the PRIMARY profile's adapter and config.
   `self.adapters` holds only the default profile's adapters (secondaries live
   in `self._profile_adapters[name]`) and `self.config` only the default's home
   channel. A secondary profile's handoff was therefore sent by the wrong bot,
   to the wrong chat, while persisting the right session key and reporting
   `handoff_state='completed'` — a false positive that looks correct in the
   database and is wrong on the wire.

Two robustness fixes in the same path:

4. Head-of-line blocking between profiles. `_process_handoff` runs a full agent
   turn plus delivery; awaiting it inline meant one slow handoff stopped the
   watcher from even polling the other profiles. With the CLI's 60s deadline, a
   valid handoff could time out purely because another profile's was ahead of
   it. Dispatch is now fire-and-forget, with an in-flight guard so a row is
   never claimed twice, and a bounded drain on shutdown.

5. Rows stranded in `running`. Only the watcher sets `running`, for the span of
   one in-process dispatch, so a row still in that state at startup belongs to
   a gateway that died mid-dispatch. It can never reach a terminal state, and
   `request_handoff` refuses new requests unless the state is
   NULL/completed/failed — that session could never hand off again, silently.
   `reclaim_stale_running_handoffs()` now fails those rows once per store at
   watcher startup. Failing (not re-queueing) is deliberate: the dead gateway
   may already have switched the session key and dispatched, so a blind retry
   risks double delivery.

Behaviour on single-profile installs is unchanged: the scope list degrades to
the unscoped root poll, `_resolve_profile_for_key` returns None when
multiplexing is off (byte-identical keys), and config/adapters fall back to
`self.config`/`self.adapters`.

Tests: 13 new across three files. Each was verified to FAIL against the
unpatched code (the fix was reverted and the suite re-run) so they are real
guards rather than decorative assertions. Verified end-to-end on a live
4-profile gateway: `handoff_state` goes failed -> completed, and a planted
stranded `running` row is reclaimed at startup with the reason recorded.
2026-08-28 11:45:19 -07:00
yoniebans be284cf5c0 fix(update): report when the official repo was not checked on the up-to-date path
Review follow-up on #97052 (helix4u): a fork with no upstream remote whose HEAD matches origin/main used to print plain "Already up to date!" under --yes even though official main was never consulted, so an unattended stale fork looked current. _sync_with_upstream_if_needed now returns whether the official upstream was actually checked, and the commit_count == 0 completion line says "Up to date with your fork (official repo not checked)." when it was not. Skip-as-decline semantics are unchanged: no prompt, no remote mutation, no decline marker. Caller-level regression test added for the fork + no-upstream + --yes + HEAD==origin/main path; helper tests now pin the return contract.
2026-08-28 13:33:56 -04:00
yoniebans b33fa127d2 fix(update): gate the fork-upstream prompt for --yes and non-tty runs
_sync_with_upstream_if_needed called bare input() with no assume_yes parameter and no tty check, so a fork checkout without an upstream remote wedged hermes update forever in any non-interactive context (CI, cron, the desktop updater hand-off): stdin stays open, EOFError never fires. Thread assume_yes and the gateway input_fn into the helper and skip the prompt as a decline under assume_yes or a non-tty stdio pair, without writing the decline marker or touching git remotes, so interactive runs still get asked later. Both call sites forward the interaction state; the config-migration and stash-restore prompts already carry this gate.

Closes #60240 (prompt half). Supersedes #78678, #92448, #92410.
Co-authored-by: BlackishGreen33 <BlackishGreen33@users.noreply.github.com>
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
Co-authored-by: jackulau <jackulau@users.noreply.github.com>
2026-08-28 13:33:56 -04:00
Brooklyn Nicholson 21cc469eb1 Merge remote-tracking branch 'origin/main' into bb/bot-mode-design-system
# Conflicts:
#	apps/desktop/src/plugins/hermes-bots/plugin.js
#	apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs
#	apps/desktop/src/sdk/profile-routing.test.ts
2026-08-28 12:05:11 -05:00
Teknium baa344dee7 refactor(process): schema diet — enum names the verbs, description keeps only non-obvious semantics; write-vs-submit trap teaching emphasized (306 -> 228 tok/call, -25%) (#97279) 2026-08-28 09:23:00 -07:00
Teknium 7b5e1911f8 refactor(todo): schema diet — item shape and merge semantics taught once, by the param schema (323 -> 232 tok/call, -28%) (#97257) 2026-08-28 08:58:12 -07:00
Teknium a9e72f1b58 refactor(read_file): schema diet + bundle anydoc 0.2.4 + typed NeedsOcrError/hosted-OCR wiring (#97195)
* refactor(read_file): capability-gate the anydoc format list; PDF coverage teaching lives in the response-time warning (426 -> 244/291 tok/call)

* feat(read_file): bundle firecrawl-anydoc 0.2.4 in core, typed NeedsOcrError handling, config-gated hosted OCR with local-OCR-first guidance

* refine(read_file): NEEDS-OCR warning hints at checking for an OCR skill without naming one; hosted_ocr knob unadvertised (maintainer-directed)

* simplify(read_file): drop the anydoc schema gate — bundled core dep makes absence a broken install, not a variant; formats stated unconditionally (263 tok/call)

* feat(read_file): PDF wording upgrades to 'scanned or text' when a trusted hosted-OCR route exists (direct key or explicit config; nous gateway excluded until Parse proxy works)

* simplify(read_file): FIRECRAWL_API_KEY is the ONLY hosted-OCR gate — nous gateway route removed (Parse proxy broken), config true no longer unlocks; false still disables
2026-08-28 08:46:11 -07:00
Teknium 306db2776c test(codex): mid-turn compaction fixtures report realistic anchored usage
The usage anchor (#97206) now trusts provider-reported usage. These two
tests simulated a tool-heavy near-overflow turn while the shared fixture
reported a 12-token prompt — the anchored pressure check honestly
concluded no pressure. Give the scenario 18K anchored prompt tokens so
the tests pin the same compaction decision they always did.
2026-08-28 07:51:31 -07:00
Teknium d3a1c46510 feat(agent): context size anchors on provider-reported usage — estimation shrinks to the last turn
Every provider response carries usage.prompt_tokens — exact ground truth
for the full request (system prompt + tool schemas + history). Context-size
checks now anchor on the last main-loop response's usage and estimate only
the messages appended since, instead of re-estimating the whole history
with chars/4 heuristics and flat 1500-token image costs. The estimate error
window shrinks from the entire conversation to one turn and self-corrects
at every response.

- agent/model_metadata.py: capture_usage_anchor() / anchored_context_tokens()
  with a structural base-message identity check that fails closed on any
  transcript rewrite.
- agent/conversation_loop.py: anchor captured at the single main-loop usage
  site (MoA uses pre-fold aggregator usage; advisor/aux calls never anchor);
  pre-API pressure check prefers the anchor.
- agent/turn_context.py: preflight compression estimate prefers the anchor.
- agent/context_breakdown.py: /context display prefers the anchor.
- Invalidation: compaction rewrite (conversation_compression), codex native
  compaction (codex_runtime), session reset/switch (run_agent), plus the
  fail-closed structural check for splices/micro-compaction.
- Usage-less responses keep the previous anchor; no anchor -> pure
  estimation fallback (first request of a session).
2026-08-28 07:51:31 -07:00
fedebyes 93f4dc7561 fix: make positional prune variant-aware; add replayed-call regression tests
Pass 2 of repair_message_sequence matched results only by id/call_id,
pruning calls answered through response_item_id or composite bridge
ids. Use the shared variant helpers (tool_call_id_variants /
tool_result_id_variants) so the unified alias policy applies
(#55626/#63000/#93251).

The positional sanitizer pass changes the crash/resume duplicate shape:
an interrupted first occurrence is now stubbed instead of deduped, so
the replayed call survives with its own immediate result. Update the
#64335 empty-key test to the new semantics and add regression tests for
the #94704 acceptance shape (historical-result + replayed-call +
fresh-call) and the production interrupted-turn shape (session
7d57a602b83d).
2026-08-28 07:51:23 -07:00
Tiberiu Danciu c7761573f5 fix: prune positionally unanswered tool_calls before API send
DeepSeek v4 rejects a payload where an assistant message carries a
tool_call whose tool result does not follow it immediately (HTTP 400
"An assistant message with 'tool_calls' must be followed by tool
messages responding to each 'tool_call_id'"). Context compression can
displace a tool result past a user turn; the result then lands ~100
messages away from its declaring assistant message.

Two gaps let the poisoned shape reach the wire (reproduced from the
production request dump of session 4d8727cbcf04, replayed through both
functions):

1. repair_message_sequence Pass 1 drops the displaced tool RESULT as
   stray but leaves the declaring assistant message carrying the now
   unanswered tool_call (with empty content) in the durable history.
2. sanitize_api_messages stubbed only globally-absent result ids: the
   displaced result still exists in the transcript, so the id survives
   the set-subtraction, no stub is injected, and the payload 400s.

Fix both layers so every path is order-independent:

- repair_message_sequence: new Pass 2 prunes tool_calls that have no
  result in the immediately-following tool run (matching on id or
  call_id, same superset rule as Pass 1). If pruning empties the turn
  (no content/reasoning left), the whole message is dropped rather than
  sending an empty assistant message. Codex interim turns are exempt,
  as in Pass 0.
- sanitize_api_messages: the orphan/stub logic is rewritten as a single
  rolling positional walk that drops results not immediately following
  their declaring assistant (including results appearing BEFORE their
  call) and injects stub results for positionally-uncovered calls even
  when a mispositioned result exists elsewhere.

Adds six regression tests: repair pruning, whole-turn drop when pruned
calls were the only payload, valid-pair negative control, positional
stub injection, result-before-call orphan drop, and a fully-paired
transcript negative control.
2026-08-28 07:51:23 -07:00