67 Commits

Author SHA1 Message Date
Hermes Agent
b90b7ae7ed fix(delegate): title subagent sessions after their goal without a model call 2026-09-26 20:40:40 -05:00
Hermes Agent
ce750ff151 fix(title): strip a quoted path's line-range suffix in the attachment-only guard
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).
2026-09-26 20:29:47 -05:00
Brooklyn Nicholson
afad9bb8ec fix(title): an attachment-only opener stays untitled, not @file: path garbage (#92068)
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>
2026-09-26 20:29:47 -05:00
kshitijk4poor
17aa453689 docs(title): point the wait-for-turn docstring at _is_self_hosted_provider 2026-09-24 17:59:28 +05:30
kshitijk4poor
0ad591d5f5 chore(agent): say "self-hosted endpoint" in the deferred-title debug log
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.
2026-09-24 17:59:28 +05:30
kshitijk4poor
a335a1b195 fix(agent): display-name title pin for a keyed providers: entry waits too
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.
2026-09-24 17:59:28 +05:30
kshitijk4poor
e74f520282 fix(agent): vllm/llama.cpp title pins also wait for the self-hosted turn
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.
2026-09-24 17:59:28 +05:30
kshitijk4poor
e1f70474e4 fix(agent): title pin spelled as the main custom route still waits for the turn
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>
2026-09-24 17:59:28 +05:30
ehz0ah
c2dfb29af5 fix(agent): defer LM Studio title upgrades
(cherry picked from commit 0fe00b3e42ed4f27099652a3e5b2df534af40137)
2026-09-24 17:59:28 +05:30
KoNit-K
194d41d91a fix(agent): defer title upgrades for named custom providers
(cherry picked from commit 679214de501bace242886d8c0154aa97f6c9e42c)
2026-09-24 17:59:28 +05:30
teknium1
efc947d72a fix: send the title model call after the turn on a shared custom endpoint
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.
2026-09-20 14:09:57 -07:00
teknium1
85f5f560c3 fix(agent): a paste-only opener's expansion footer must not demote the title preview
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.
2026-09-18 10:56:00 -07:00
teknium1
34ba61bf67 fix(agent): paste title hint reaches the instant title and the prompt.submit contract
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).
2026-09-18 10:56:00 -07:00
KoNit-K
8d25e69b6e fix(desktop): use generated paste previews for titles 2026-09-18 10:56:00 -07:00
teknium1
96b6c534c3 fix(title): demote only the exact greeting placeholder, not prefixed topical titles
_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.
2026-09-18 09:58:45 -07:00
teknium1
f85d9910fa fix(agent): a placeholder title gets re-titled by the first substantive turn
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>
2026-09-18 09:58:45 -07:00
KoNit-K
cc19fcf949 fix(agent): upgrade provisional greeting titles
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>
2026-09-18 09:58:45 -07:00
teknium1
eff18764ff fix(aux): bracket-prefixed and fence-containing prose titles are kept
`_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).
2026-09-17 09:07:08 -07:00
teknium1
0b52e40347 fix: auto-titles no longer drop reasoning-model JSON or store truncated fragments
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>
2026-09-17 09:07:08 -07:00
JonthanaHanh
90ae99aa66 fix(agent): use provider-default temperature for title generation (#72351)
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
2026-09-17 08:45:46 -07:00
teknium1
c02616acb3 fix(aux): model_upgrade_enabled only silences the automatic title upgrade
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).
2026-09-17 08:45:08 -07:00
Gustavo Payano
06387ae8d3 fix(title): keep the derived title while skipping the model upgrade (#85194)
`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`).
2026-09-17 08:45:08 -07:00
teknium1
8899aeff53 fix(oneshot): --usage-file ledger reports auxiliary LLM spend
`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>
2026-09-16 17:03:25 -07:00
teknium
9977df914d fix(kanban): delegate_task children never act on the parent's HERMES_KANBAN_TASK
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
2026-09-16 10:43:48 -07:00
teknium1
1b7355d7fa fix: cap kanban card titles so an overlong card still names its worker
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.
2026-09-15 06:08:20 -07:00
teknium1
c12a3397b1 fix(kanban): read the worker's card title from the board instead of a new env var
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.
2026-09-15 06:08:20 -07:00
KoNit-K
56555f88df fix(kanban): name worker sessions from task title 2026-09-15 06:08:20 -07:00
teknium1
8b181940b4 fix(multiplex): background threads and teardown paths carry the turn's profile scope
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.
2026-09-15 03:47:15 -07:00
Teknium
f0d194df81 Port from QwenLM/qwen-code#9709: reject session titles that echo the prompt's own examples
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.
2026-09-13 21:08:32 -07:00
liuhao1024
5ea655771b fix(agent): disable reasoning on the title-generation pass 2026-09-12 21:10:02 -07:00
Teknium
d98c48b410 review-fix(comments): restore lost rationale in the 7 files the sweep had to skip (docstring/comment-only) 2026-09-03 10:40:25 -07:00
Teknium
f2fceda404 refactor(agent/E_session): final code compaction — inline single-use predicates, tail() keyword helper, dict literals, elif chains 2026-09-02 23:08:45 -07:00
Teknium
63f1950b4f refactor(agent/E_session): by-hand docstring/comment compaction across 9 files; transcript_repair row lookup helper; activity snapshot literal 2026-09-02 22:23:45 -07:00
Teknium
f6fe5452de refactor(agent/title_generator): compact constant tables, collapse redundant empty-guards 2026-09-02 20:57:06 -07:00
Teknium
34ac5f326a refactor(agent/title_generator): _strip_one_wrapper helper, flatten derive_title cut 2026-09-02 19:41:53 -07:00
Teknium
334b72256a refactor(agent/E_session): contextlib.suppress for swallow-only try/except 2026-09-02 19:27:20 -07:00
Teknium
59dfa83260 refactor(agent/E_session): compact docstrings/comments by hand, keep every WHY 2026-09-02 19:13:00 -07:00
Teknium
b269b9c2a3 refactor(agent/title_generator): _has_upgraded_title/_first_line helpers 2026-09-02 19:05:50 -07:00
Teknium
c01255c90b refactor(agent/title_generator): unify title-prefix strip, compact docstrings 2026-09-02 18:24:28 -07:00
Teknium
c408601937 refactor(agent/review): simplify curator, background_review, verify, insights, title and learning modules (-22% LOC)
Cluster: agent/{curator,curator_backup,background_review,review_engine,
review_idle_queue,insights,learning_graph,learning_graph_render,
learning_mutations,learn_prompt,verification_evidence,verification_stop,
verify_hooks,side_question,title_generator,turn_summary,
manual_compression_feedback,trajectory,moa_trace,trace_upload,verify/*}.
13662 -> 10693 LOC (-2969, -21.7%), behavior-neutral.

- Dead code: 27 private helpers with zero references removed
  (_auto_title_session, _resolve_review_model, _parse_make_targets,
  _filter_verifiable_paths, _find_subsequence, _is_under_root/_temp_dir,
  _merge_runs, learning_graph_render bucket/period/node helpers,
  _memories_dir/_memory_local_index/_node_detail, _cron_jobs_file,
  _retention_cutoff, _scope_for_args, _clean_token, _count_diff_lines,
  _ordered_verbs, _hermes_meta, _iter_skill_files).
- Unified helpers: _read_config_section (curator + curator_backup),
  _write_file/_write_json (4 curator report writers), _msg_text
  (background_review <- side_question), _report_failure/_notify_title
  (title_generator instant/auto paths), _is_under (verification_evidence),
  _scoped SQL pair builder + _query (insights), _optional_lock
  (background_review), verify.recipes table-driven detection.
- if/elif routing -> dict dispatch: side_question role labels,
  curator_backup summary bits, learning_graph_render buckets, insights
  section rendering, verify recipe pickers.
- Redundant defensive layers, single-use wrappers and verbose narrative
  comments collapsed; every non-obvious WHY/invariant kept in compact form.

Verification: parity.py (all REMOVED symbols zero-ref), import smoke for
every module + cli/run_agent/gateway.run/hermes_cli.main/
agent.conversation_loop/tui_gateway.server, old-vs-new fuzz parity on all
shared pure functions, SQL trace parity for insights and
verification_evidence, cluster tests 1354 passed / 0 failed (46 files).
2026-09-02 13:30:25 -07:00
Teknium
d5167831b8 Port from can1357/oh-my-pi#7306: reject answer-shaped auto-title output
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.
2026-08-16 22:10:02 -07:00
Brooklyn Nicholson
c0d502db64 refactor(title): decide on the stored title and the real turns behind it
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>
2026-08-09 04:33:58 -05:00
yy28
b684cbb094 fix(title): stop model-switch marker from becoming the session title
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.
2026-08-09 04:33:58 -05:00
Brooklyn Nicholson
53a4003208 fix(agent): stop titling a session after our own scaffolding, or a TTS model
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.
2026-08-09 04:33:58 -05:00
Brooklyn Nicholson
34577fcb03 fix(gateway): rename a Discord thread once, after the reply lands
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.
2026-08-09 04:33:58 -05:00
Brooklyn Nicholson
f726090d48 feat(sessions): name a session the moment it starts
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.
2026-08-08 17:07:21 -05:00
Gabriel Stoltemberg
59ee85ed50 perf: use load_config_readonly() at read-only call sites in agent/
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.
2026-07-29 15:28:15 -07:00
Brooklyn Nicholson
dcf5fc0eea fix(sessions): stop titling a session after the skill it invoked
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.
2026-07-26 02:58:16 -05:00
Thatgfsj
ef1c622105 fix(title): prevent stale background title generation from reloading unloaded Ollama models
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.
2026-07-16 23:07:13 -07:00
Trevor Gordon
9bf5822a2f fix(cron): robust session title generation (#50535, #50536, #50537) 2026-07-16 22:39:47 -07:00