A quoted (space-bearing) @file:/@folder: reference may carry a :start[-end]
line range; the canonical parser (context_references.REFERENCE_PATTERN)
claims the whole token. The guard's local copy stopped at the closing quote,
leaving ":3" as residual "prose", so a quoted, line-ranged attachment-only
opener kept a path-derived title (#92068 regression found in review of #122000).
Mirror the canonical value shape and pin all three quote styles (#122000).
The manual-attach path (composer attach chip / hand-typed @file:) sends no
Desktop paste preview, so the build_title_input ref-only shortcut never fires
and derive_title names the session after the truncated file path
(title_source='derived' DB rows from the issue).
An opener that reduces to nothing but @file:/@folder: context references
(and their expansion footer) is a file drop, not a request: refuse it at
every title entry point — is_titleable_user_message, derive_title, and
generate_title (reached via auto_title_session, which lacks the
instant-title guard). Prose around an attachment keeps titling from the
prose; the paste-preview path is untouched.
Co-authored-by: andyst-dev <andy@example.com>
Co-authored-by: fangliquanflq <fangliquan@example.com>
The gate now defers for custom:<name>, lmstudio and local (vllm/llama.cpp)
routes, not only bare `custom`, so "custom endpoint" misdescribes why the
upgrade waited.
A keyed `providers:` entry runs as custom:<key>, but its display name does
not alias that id ("GPTOSS Local" -> custom:gptoss-local vs custom:gptoss),
so a pin spelled as the display name fired concurrently with the turn on
the same single-slot server (#120558 gate W2), even though the auxiliary
resolver accepts it as that endpoint. When the alias match misses, resolve
the pin through the side-effect-free config lookup
(resolve_custom_provider over get_compatible_custom_providers) and treat it
as shared when its base_url equals the main route's.
normalize_provider maps vllm/llamacpp/llama.cpp/llama-cpp to `local`, and
the auxiliary resolver sends those pins to the same local server as the
turn (#106010), but _is_self_hosted_provider only knew custom/lmstudio, so
a `vllm` title pin still fired concurrently with the main request (#120558
gate W1). Treat `local` as self-hosted and normalise inside the helper so
the main route and the pin resolve aliases (ollama, lm-studio) the same
way. The pin check now runs inside the gate's existing fail-open block
instead of its own defensive try/except around the providers import.
title_upgrade_must_wait_for_turn returned False as soon as
auxiliary.title_generation.provider was anything other than
""/auto/custom, so a pin of `custom:<name>`, the bare config name or the
display name of the very route the turn is running on fired the
json_schema title request at turn start against the same single-slot
server (#117296 race, #120558 follow-up raised in review).
A pin is now only treated as "elsewhere" when it is a hosted provider, or
when its own base_url differs. Self-hosted pins (custom / custom:<name> /
lmstudio, incl. aliases via normalize_provider) fall through to the
existing base_url comparison; a bare/display-name pin matches the main
`custom:<name>` route through hermes_cli.providers.custom_provider_aliases,
the same identity set the resolver uses.
Co-authored-by: ehz0ah <haozhe4547@gmail.com>
Co-authored-by: Brian Fernstrom <otstructures@gmail.com>
On a `custom` main route (llama.cpp, Ollama, vLLM, ...) whose
auxiliary.title_generation is not pinned elsewhere, the turn prologue fired the
`response_format: json_schema` title request on a daemon thread at the same
instant as the turn's own streaming request, against the same self-hosted
server. A single-slot server can decode the title grammar/completion into the
main reply: the user then receives `{"title": ...}` as the assistant turn, the
main loop persists it as a genuine assistant row, replays it, and the model
adopts the format (#117296). No Hermes writer routes the aux response into the
transcript; the leaked JSON is the main completion itself.
`maybe_auto_title` now returns the upgrade thread and leaves it UNSTARTED when
`title_upgrade_must_wait_for_turn(main_runtime)`; the prologue parks it on
`agent._deferred_title_upgrade` and `finalize_turn` starts it once the model
has answered. Hosted providers keep the turn-start timing. Usage accounting
(`task='title_generation'`) and `sessions.title` are unchanged.
The titler receives the opening message AFTER @-reference expansion, so
the Desktop's generated pasted_content @file: ref arrives with a
'--- Context Warnings ---' (or '--- Attached Context ---') footer. That
footer made the ref-only check in build_title_input fail, so the live
wire capture showed the path + warning leading the title-model input
with 'Pasted content: ...' appended after it. Strip the expander footer
when a preview is present, so a paste-only opener lets the preview lead.
Invariant: test_expanded_paste_ref_footer_does_not_demote_the_preview
(red on a5dac801, green here). Re-checked on the wire against the stub
model: title input now starts with the pasted topic, no @file:/warning.
Build on #114129 (@KoNit-K), which carries a Desktop-generated large-paste
preview from the composer through `prompt.submit` -> `display_metadata` ->
turn context -> the shared title input. Two gaps closed:
- `apply_instant_title` never received the preview, so the instant title of a
paste-only opener was the generated `@file:` path — and stayed that way,
because the upgrade thread's `derive_title` fallback writes `derived`
provenance, which never replaces the `derived` title already stored.
Thread the hint into the instant stage too.
- `build_title_input` let the `@file:` ref lead when the opener was nothing
but the generated attachment ref; the preview now leads for a ref-only
opener (an instruction still leads when the user typed one).
- `prompt.submit` gains `title_preview` in the contract (regenerated shared
TS/OpenRPC); documented as title-only input in the configuration guide.
- Tests trimmed to two invariants (shared input reaches both stages; budget +
manual attachments stay unread).
_is_provisional_greeting_title used a prefix match, so a real model title
such as 'Friendly greeting card design' was ranked derived; derived never
replaces derived, and after turn 3 the session locked without an llm title
(the gateway topic-rename lane consumes llm titles only). Match the
placeholder exactly ('friendly greeting' / 'friendly greeting in chat').
Also re-shape the turn-3 cap test so it fails on an uncapped or never-retrying
variant: a derived title still gets a model call on turn 3, none on turn 4.
maybe_auto_title used to skip any turn past the opener once the session had a
title of ANY provenance, so a greeting opener locked the name for good: the
instant "hi how are you" (derived) blocked the demoted greeting title, and
nothing ever asked again. Gate the skip on an llm/user title instead, so a
derived placeholder is replaced by the first real request; cap the retry at
turn 3 so a failing title model does not cost one call per turn. Untitled
sessions keep their unlimited retry.
Detect the greeting placeholder by prefix ("Friendly greeting in chat" too)
and keep it as a prompt example on purpose: a predictable placeholder is
detectable, an improvised one would land as llm and lock the title again.
Fixes#113864
Co-authored-by: eminogrande <eminogrande@users.noreply.github.com>
The model title for a bare greeting ("Friendly greeting") is persisted at
derived authority instead of llm, so it is a placeholder like the instant
title rather than the session's final name.
Part of #113864
Co-authored-by: eminogrande <eminogrande@users.noreply.github.com>
`_is_truncated_structured_output` flagged any reply starting with `{`/`[` or
containing an odd number of fences, so a provider that ignores response_format
and answers `[WIP] Fix login flow` or `Fix ``` rendering in chat` lost its
title (the derived title stayed). Truncated JSON is recognised by its JSON-shaped
opener (`{"`, `["`, `[{`) and an unclosed fence only when the fence opens the
reply — the fragments from #83903 are still dropped.
Review follow-up on #113960; two legit-title cases added to the existing
parametrized invariant (red on the previous head).
Two failures of the title upgrade shared one root cause: the parser only
looked at message.content and trusted whatever it found there.
- A reasoning model under the json_schema response_format (glm-5,
minimax on opencode-go) returns content='' with the {"title": ...}
payload in reasoning_content / reasoning. generate_title read only
content, so the session kept its derived title forever (#82291). The
structured extraction now also looks at those fields — structured only,
so chain-of-thought prose can never become a title.
- max_tokens=64 cut fenced/prefixed JSON mid-value and the prose fallback
persisted the fragment ('{"title":"Investigate and fix the login butt',
'```json') as the session title (#83903). Truncated structured output
(leading brace/bracket, unclosed fence) is now rejected so the derived
title survives, and the ceiling is 512 — a compliant model stops after
~15 tokens regardless, so the budget only costs on replies that would
have been garbage anyway.
Live: stand-in replaying the issues' logged bodies through the real
generate_title path, before: fragment titles / None, after: correct title
or None; OpenRouter glm-5 and OpenAI controls unchanged.
Co-authored-by: chenlichao <chenlichao@users.noreply.github.com>
Co-authored-by: olympusbuildz <olympusbuildz@users.noreply.github.com>
Co-authored-by: woshicby <woshicby@users.noreply.github.com>
Title generation hardcoded temperature=0.3 in its call_llm() call.
Models like GPT-5.6 only accept their server-side default temperature
and reject explicit values with "Unsupported value: 'temperature'".
While call_llm() has a retry that strips temperature on error, the
daemon thread races with session cleanup in short-lived CLI sessions,
causing the retry to fail with a connection error.
Fix: pass temperature=None so the provider uses its own default. This
avoids the unsupported-temperature error entirely and eliminates the
need for the retry path.
Fixes#72351
The gate also sat inside `generate_title`, so the explicit operator repair
command `hermes sessions retitle-skills` returned None for every row when the
toggle was off, although the operator asked for a model call. The toggle's
contract is "never spend a model call upgrading the instant title": only
`maybe_auto_title` (the background path) is gated now; a direct
`generate_title` call still asks the model. Docs sentence adjusted.
Review follow-up on #113955; the existing toggle test now also asserts the
explicit path still titles (red on the previous head).
`auxiliary.title_generation.enabled: false` turned off both title stages, so an
operator who only wanted to stop the background model call (unavailable or
metered endpoint) also lost the instant derived title. `model_upgrade_enabled:
false` keeps the derived title and starts no `auto-title` thread; missing keeps
the two-stage default and `enabled: false` still disables both.
Salvaged from #85401 onto current main: the gate reuses `_title_config()` and
sits before `spawn_context_thread` (the thread seam moved off `threading.Thread`).
`hermes -z --usage-file` copied only the main-loop result, so title generation,
vision, compression, web_extract and background-review calls — recorded per task
in session_model_usage — never reached the pipeline ledger the flag advertises
as "so pipelines can always account for spend". The Insights page already folds
those rows in (#23270); the ledger is now consistent with it.
- SessionDB.auxiliary_usage_by_task(session_id): per-task sums over the
session's compression lineage (aux calls bill to the id the turn started with
while compression mints child ids mid-turn).
- oneshot snapshots aux usage before the turn and attaches the delta after it,
so a resumed session's earlier runs are not re-billed.
- The report gains `auxiliary` (totals + `by_task`) and
`total_including_auxiliary`; every existing key keeps its main-loop meaning.
- The auto-title upgrade runs on a daemon thread and can still be writing when
the turn returns: title_generator tracks in-flight upgrade threads and
oneshot joins them (bounded) before reading — no sleep, no eager read.
Fixes#112848. Direction shared with #112852 (@KoNit-K), which folded aux into
the headline counters; this keeps them backward compatible instead.
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
In-process delegate_task children (and cron runs fired from a worker) inherit
the dispatcher worker's HERMES_KANBAN_TASK via os.environ. Three readers still
gated on the bare env var instead of is_dispatcher_owned_worker_context():
- agent/turn_finalizer.py: a child exhausting ITS iteration budget recorded
`timed_out` against the parent's task and released the parent's claim.
- tools/kanban_tools.py::inject_new_comments_from_env: a child polled operator
notes addressed to the worker, steered itself with them, and advanced the
shared per-task watermark so the worker never saw them.
- agent/title_generator.py::_kanban_task_title: a child's session was titled
after the parent's card.
Each now uses the single identity predicate. Dispatcher-owned workers are
unchanged (existing #87096 tests still pass).
Fixes#112817
Kanban cards have no length limit, but the session title store rejects
titles past SessionDB.MAX_TITLE_LENGTH with ValueError. _persist_session_title
reads that as a unique-title collision, retries with a "#N" suffix (longer
still), and the caller suppresses the second failure - so a worker spawned on
a >100-char card ended up with no title at all, where main at least gave it a
derived one. Trim the card title (with room for the "#N" retry suffix) before
persisting; a retried card now gets "<trimmed> #2" within the cap.
Review finding: >100-char card title left the kanban worker session untitled.
Follow-up to the salvaged commit from #111169 (@KoNit-K):
- Drop HERMES_KANBAN_TASK_TITLE. The worker already has HERMES_KANBAN_TASK
and HERMES_KANBAN_BOARD/HERMES_KANBAN_DB pinned in its env, so
maybe_auto_title reads the card title from the board itself (no new
HERMES_* env var for non-secret config; the dispatcher and the
delegation scrub list stay untouched).
- Unreadable or missing card: the session is named `Kanban task <id>`
with zero auxiliary calls (the fallback the issue asked for; the
#109743 seed left such workers untitled).
- The card title persists at `llm` authority via set_auto_title, so a
manual /title still wins and the upgrade thread never starts.
- Tests trimmed to two invariants against a real board + SessionDB
(card title, unreadable-card fallback), both red on origin/main.
The profile scope (HERMES_HOME override, secret scope, terminal policy) is a
contextvar bundle bound per turn. A bare threading.Thread / Timer / gRPC
callback starts with an empty context and resolves the LAUNCH profile:
- agent/title_generator.py: the auto-title thread read
auxiliary.title_generation (model, language, provider key) from the default
profile's config and billed the default's key for a secondary's session.
Spawn via agent.memory_provider.spawn_context_thread (copy_context).
- tui_gateway/session_lifecycle.py: every teardown caller is a bare Timer
(ws-orphan reap), the idle-reaper thread, atexit _shutdown_sessions, the
session.close pool RPC, superseded_by_resume or compute_host flush - none
carries a scope, yet on_session_end / commit_memory_session / agent.close ->
shutdown_memory_provider read the provider's config + credentials at call
time. Under multiplex they failed closed (tail never committed, #110622
class); on the Desktop backend a secondary's transcript went to the launch
profile's memory tenant. _finalize_session and _teardown_session now bind
_session_profile_runtime_scope(session) around those blocks, which covers
every spawn site through the single chokepoint.
- plugins/platforms/google_chat/adapter.py: Pub/Sub callbacks run on the gRPC
SubscriberClient's threads and run_coroutine_threadsafe copies THAT empty
context onto the loop task, so _dispatch_message and everything under it
(attachment cache, per-user OAuth token store via _acquire_user_chat_api ->
_load_per_user_chat_api, TTS keys, delivery ledger, bot-id cache) resolved
the launch profile. connect() captures its scope; _on_pubsub_message and
_submit_on_loop run under a per-callback copy of it.
spawn_context_thread gains a kwargs passthrough for the title thread's
callbacks.
Small title models parroting a prompt example back verbatim produced
sessions named "Fix login button on mobile" with no relation to the
conversation. The example lines in _TITLE_PROMPT_TEMPLATE now render
from _PROMPT_GOOD_EXAMPLES so the guard set and prompt cannot drift,
and generate_title rejects exact (case-insensitive, wrapper-stripped)
echoes so the instant derived title survives instead. 'Friendly
greeting' stays allowed — it is prescribed output for bare greetings.
A tiny title model that ignores the 3-7 word titling task and answers
the user's first message instead used to have its whole reply stored
(truncated at 80 chars) as the session title. Truncating an assistant
blob still leaves an assistant blob — generate_title now rejects output
over 12 words and returns None, letting maybe_auto_title retry on the
next exchange. The 80-char truncation remains for genuine-but-wordy
titles that pass the word bound.
Folds the model-switch fix in with the untitled retry. They answer
different halves and each is wrong alone: counting alone left a session
that merely opened with machinery nameless forever, because nothing
reconsidered it, and the stored title alone would never title at all on a
store too old to report one. Skip only when both agree — past the opening
turn, and already named.
Counting a turn now judges a multimodal one on its text, so "here's a
screenshot, fix the login" counts as the question it is rather than
reading as machinery and undercounting the conversation.
Co-authored-by: yy28 <yy28@vip.sina.com>
Switching models before sending the first real message titled the session
"[System: The active model for this chat has…" instead of the user's actual
question.
`_append_model_switch_marker` persists its notice with `role="user"` because
strict OpenAI-compatible providers reject a system message that is not first
(#48338). Titling had no way to tell that apart from a genuine opening turn,
which caused two distinct failures:
1. `_MACHINE_PREFIXES` did not cover the marker. Its `[System: ` prefix
matches none of `[CONTEXT COMPACTION`, `[Runtime note:`, or `[SYSTEM]`
(different case, no closing bracket), so `is_titleable_user_message()`
returned True and the marker was formatted into the title.
2. `maybe_auto_title()` counted the marker as a user message. With the marker
present, the first real question arrived at `user_msg_count == 2` and the
`> 1` guard returned early, so the session was never titled at all and its
`title` column stayed NULL. Fixing only (1) would therefore have traded a
wrong title for a permanently missing one.
Add the marker prefix to `_MACHINE_PREFIXES` (kept in sync with
`tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX`) and count only titleable
user messages when detecting the opening turn.
The guard stays narrow: ordinary user text that happens to start with
"[System:" still titles normally.
Adds 6 regression tests, verified to fail without the fix.
Two lookalike gaps found auditing the titler.
_MACHINE_PREFIXES missed the compressor's legacy summary opener and the
"[System note:" injections, so a compacted or resumed session could be
named after the note that carried it. Take the summary prefix from the
compressor that emits it rather than keeping a fourth local copy.
The fast-model exclude list covered embedders but not the other non-chat
siblings a provider names after its chat model — "gpt-4o-mini-tts"
satisfies the "-mini" rung and cannot answer a prompt.
Titling is two-stage — a slice of the user's own words lands inline, the
model's version replaces it a second later — and the platform rename lanes
fired on both. That is two rate-limited calls to reach one name, and
Discord allows two channel renames per ten minutes, so the throwaway could
be the one that survived. The callback now carries which stage it is, and
the lanes take the model's.
The relay lane also asked where the reply landed at title time, which is
before the model has answered: it polled the send-result cache for ten
seconds and read the timeout as "never auto-threaded", so any turn with
tool calls in it silently kept its raw thread name. Wait on the send
itself instead — the adapter already owns that cache, so it can say when a
reply arrives and, just as usefully, that one arrived carrying nothing.
Titling fired on the first response, so a session sat unnamed for the whole
opening turn - p50 151s, p90 1212s across real sessions, because a turn is
tool calls, not one round-trip. A turn that failed or was interrupted never
got a title at all. Four surfaces each carried their own copy of the call.
Move it into the shared turn prologue and split it in two: a deterministic
title derived from the user's opening message, written inline before the
model runs, then one small-model call that upgrades it. The response is
constrained to a JSON object so there is no preamble to strip, and control
wrappers are stripped rather than refused, so a slash command titles as
what the user asked for instead of the command itself.
Salvaged from #56085 (@Stoltemberg), rebased onto current main: sites
main had already converted (credential_pool, auxiliary_client MoA
paths, model_metadata, moa_loop, agent_runtime_helpers) resolve to
main's versions; the remaining ~29 read-only sites across 16 agent/
files swap to the no-deepcopy readonly loader (~135us saved per call).
Full per-site mutation audit performed (every enclosing function read,
escapes traced): 23 SAFE, 5 ESCAPES with read-only consumers, 1 UNSAFE
path (init_agent -> get_compatible_custom_providers -> normalizer
in-place alias writes) fixed by the preceding no-mutate commits, which
make the normalizer copy-safe for ALL callers.
generate_title() sent the first 500 characters of the user turn to the
auxiliary model. On a /skill invocation those characters are the skill's own
opening prose, so the session got named after the skill instead of the request
— /work sessions came back as "Isolated Git Worktree Setup".
Route the turn through describe_skill_invocation() first, so the titler sees
what the user typed. Also keep only the first line of the response: a model
that ignores "return ONLY the title" and answers the prompt would otherwise
have a shell transcript stored as the title, truncated mid-command.
Add a runtime_validator callback to generate_title() / auto_title_session()
/ maybe_auto_title(). Callers snapshot the session's model+provider when
spawning the background titler; the validator runs right before the LLM
request and skips it silently when the live runtime no longer matches —
so a stale title request can't reload a model that strict_single_load
already evicted after a user model switch. Fail-open: a raising validator
never disables titling.
Wired at all four call sites (cli, gateway, tui_gateway, acp_adapter).
Surgical reapply of PR #19137 (base was 8k+ commits stale; the original
patch predates the pinned-language prompts, the atomic-write helper, and
the moved TUI/ACP call sites). Original work by @Thatgfsj. Closes#19027.