Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
c92a95a
feat(desktop): move model selector from statusbar to composer
OutThisLife Jun 16, 2026
989d5d0
fix(desktop): declutter date-pinned model snapshots in the picker
OutThisLife Jun 16, 2026
0e81d2f
feat(desktop): per-model effort/fast presets in the picker
OutThisLife Jun 16, 2026
a0ec4f5
feat(desktop): disconnect external (CLI-managed) providers
OutThisLife Jun 16, 2026
dd0e3e0
fix(desktop): tighten thread content top padding
OutThisLife Jun 16, 2026
630b438
fix(models): merge live API results with curated static catalog in ge…
liuhao1024 Jun 15, 2026
ee7b8a4
fix(models): validate_requested_model falls back to curated catalog w…
liuhao1024 Jun 16, 2026
cb6b412
refactor(desktop): make composer model picker sticky session state
OutThisLife Jun 16, 2026
7d938cc
fix(desktop): keep live model switch metadata truthful
OutThisLife Jun 16, 2026
80e4b89
feat(desktop): tighten composer model picker interactions
OutThisLife Jun 16, 2026
c6e99ab
Merge pull request #46959 from NousResearch/bb/composer-model-selector
OutThisLife Jun 16, 2026
5094325
feat(skills): replace shop-app with CLI-based shop skill (v1.0.1)
joerj123 Jun 16, 2026
d7668aa
chore(skills/shop): tighten description to ≤60 chars, credit contributor
teknium1 Jun 16, 2026
cf52370
chore(release): AUTHOR_MAP entry for Joe Rinaldi Johnson
teknium1 Jun 16, 2026
e236bb8
docs(skills): regenerate shop skill page after shop-app rename
teknium1 Jun 16, 2026
e3adbb5
fix(openviking): sanitize skill memory input
ehz0ah May 26, 2026
c2c55c4
fix(memory): strip skill scaffolding for all providers, not just open…
teknium1 Jun 16, 2026
658ac1d
fix(models): keep curated-first ordering in live+curated merge; use p…
kshitijk4poor Jun 16, 2026
17251e8
Merge pull request #46857 from liuhao1024/fix/model-picker-merge-live…
kshitijk4poor Jun 16, 2026
b2da39a
feat: add z-ai/glm-5.2 to OpenRouter and Nous model lists
kshitijk4poor Jun 16, 2026
f6a42b1
feat(prompt): make context-file truncation limit configurable
WolframRavenwolf Apr 11, 2026
6ebc449
fix(prompt): isolate truncation warnings per context
teknium1 Jun 16, 2026
44e5848
feat(desktop): stream subagent activity into watch windows (#47060)
OutThisLife Jun 16, 2026
8fa562a
Merge pull request #47391 from kshitijk4poor/feat/add-glm-5.2
kshitijk4poor Jun 16, 2026
e76e7b5
feat(hooks): session:compress event_callback for MemPalace sync
WolframRavenwolf Apr 13, 2026
28f9247
test(hooks): cover session:compress event; drop dead import
teknium1 Jun 16, 2026
bbc842d
feat(xai): default to grok-build-0.1
Jaaneek Jun 16, 2026
f4ef70f
docs(xai): update default model references to grok-build-0.1
Jaaneek Jun 16, 2026
b7fa62c
fix(inventory): keep user-defined custom providers in model dedup
cyb0rgk1tty Jun 15, 2026
db01910
chore(release): map cyb0rgk1tty noreply email for AUTHOR_MAP
teknium1 Jun 16, 2026
01ae9b8
fix(telegram): resolve replies to rich (sendRichMessage) messages
x1erra Jun 16, 2026
3f80bca
chore(release): AUTHOR_MAP entry for x1erra (Sierra)
teknium1 Jun 16, 2026
8ed16a7
test(telegram): rich-reply recovery via send-time index
teknium1 Jun 16, 2026
1039e90
fix(model-switch): probe /v1/models for providers without api_key
chimpera May 21, 2026
7493de7
test(model-switch): cover section-3 no-auth probe; map chimpera author
teknium1 Jun 16, 2026
9137b86
fix(skills): ignore support docs in skill discovery
WolframRavenwolf Jun 16, 2026
1b962f0
fix(models): pass model.base_url to fetch_models in /model picker
liuhao1024 Jun 16, 2026
db44af0
test(model-picker): cover two overlapping user-defined custom providers
teknium1 Jun 16, 2026
d1ecebc
fix(desktop): re-download Electron binary via mirror when pack fails …
xxxigm Jun 16, 2026
b7f0c9c
fix(desktop): honor pre-session model pick + restore global reasoning…
OutThisLife Jun 16, 2026
bd7fc8f
feat(gateway): inject stable human-readable message timestamps
WolframRavenwolf May 18, 2026
36ae958
feat(gateway): gate message timestamps behind opt-in (default off)
teknium1 Jun 16, 2026
5e01a5d
fix(cli): detect containerd/CRI cgroup-v2 containers in is_container(…
Bartok9 Jun 17, 2026
547a014
fix(desktop): avoid stack overflow rendering huge fenced blocks
OutThisLife Jun 17, 2026
b82eca2
fix(desktop): isolate message render crashes from the root boundary
OutThisLife Jun 17, 2026
f48b312
fix(cli): keep typing responsive by not blocking the keystroke loop
xxxigm Jun 17, 2026
fbaad30
test(cli): URL tokens must not trigger filesystem path completion
kshitijk4poor Jun 17, 2026
ca6542f
docs(cli): note URL exclusion in _extract_path_word docstring
kshitijk4poor Jun 17, 2026
9901141
Merge pull request #47701 from kshitijk4poor/salvage/cli-completer-ke…
kshitijk4poor Jun 17, 2026
a7ec334
fix(cli): deprecated `hermes login` fails gracefully for any provider
kshitijk4poor Jun 17, 2026
3d37869
fix(anthropic): use double-underscore mcp__ prefix for OAuth tool names
liuhao1024 Jun 15, 2026
b70a4e7
fix(anthropic): also normalize MCP-server tool names to mcp__ on OAut…
kshitijk4poor Jun 17, 2026
f9c8d95
Merge pull request #47723 from NousResearch/salvage/oauth-mcp-prefix
kshitijk4poor Jun 17, 2026
435c706
fix(desktop): stop a failed turn leaking into every other thread
OutThisLife Jun 17, 2026
4d39a60
fix(codex): restore session_id/x-client-request-id HTTP headers for c…
kyssta-exe Jun 16, 2026
e48803d
fix(gateway): defer macOS launchd reload when run inside the gateway …
teknium1 Jun 17, 2026
7bbffce
feat(curator): make skill consolidation opt-in (prune stays default-o…
teknium1 Jun 17, 2026
fc1119c
fix(curator): stop the rollback safety snapshot from pruning its target
MaxFreedomPollard Jun 17, 2026
f4100f4
fix(desktop): list markers and quote border follow RTL message direction
Adolanium Jun 12, 2026
49ef024
chore(release): map Adolanium author email for PR #44628 salvage
teknium1 Jun 17, 2026
f80381c
feat(prompt): scale context-file cap to model window + point agent at…
teknium1 Jun 17, 2026
674e8b0
Fix dashboard gateway profile scoping
shannonsands Jun 17, 2026
dc86d48
fix(dashboard): use await-safe config-only scope for /api/status profile
teknium1 Jun 17, 2026
06d907d
fix(dashboard): only run runtime-pid liveness fallback against local …
teknium1 Jun 17, 2026
cbfa018
fix(auth): retry Codex device-code login on 429 with clear rate-limit…
teknium1 Jun 17, 2026
992b922
fix(curator): stop restore from matching unrelated skills by name prefix
MaxFreedomPollard Jun 17, 2026
0138282
perf(desktop): keep oversized messages from freezing the chat
OutThisLife Jun 17, 2026
f10f711
Merge pull request #47664 from NousResearch/bb/desktop-markdown-sprea…
OutThisLife Jun 17, 2026
c6c8abb
refactor: remove agent-callable send_message tool (#47856)
teknium1 Jun 17, 2026
c2fa302
Merge pull request #47913 from xxxigm/fix/desktop-backend-skew-toast-nag
xxxigm Jun 17, 2026
3d21666
fix: preserve multimodal user content during persistence
Rivuza Jun 11, 2026
cc9f37e
chore: map Rivuza to AUTHOR_MAP for #44249 salvage
teknium1 Jun 17, 2026
eaddeaf
feat(xai): add grok-composer-2.5-fast to xAI OAuth model picker
definitelynotguru Jun 17, 2026
aa6f775
chore: add AUTHOR_MAP entry for #47904 salvage
teknium1 Jun 17, 2026
49d7481
Merge pull request #47706 from NousResearch/fix/cli-login-deprecation…
kshitijk4poor Jun 17, 2026
63c3d68
Merge remote-tracking branch 'upstream/main' into HEAD
alt-glitch Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse

from agent.context_compressor import ContextCompressor
Expand Down Expand Up @@ -195,6 +195,7 @@ def init_agent(
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
Expand Down Expand Up @@ -426,6 +427,7 @@ def init_agent(
agent.status_callback = status_callback
agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.tool_gen_callback = tool_gen_callback


Expand Down Expand Up @@ -597,6 +599,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None

# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the
Expand Down
43 changes: 32 additions & 11 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:


_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp_"
_MCP_TOOL_PREFIX = "mcp__"


def _get_claude_code_version() -> str:
Expand Down Expand Up @@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic")
block["text"] = text

# 3. Prefix tool names with mcp_ (Claude Code convention)
# Skip names that already begin with the marker — native MCP server
# tools (from mcp_servers: in config.yaml) are registered under their
# full mcp_<server>_<tool> name and would double-prefix otherwise,
# breaking round-trip registry lookup in normalize_response. GH-25255.
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# billing classifier treats a single-underscore ``mcp_`` tool name as
# a third-party-app fingerprint and rejects the request with HTTP 400
# "Third-party apps now draw from extra usage, not plan limits"
# (verified empirically: a single ``mcp_foo`` tool flips a request
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
#
# Two cases, both must land on the double-underscore ``mcp__`` form:
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
# b) native MCP server tools registered under their full
# single-underscore ``mcp_<server>_<tool>`` name
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
# left open: those tools were *skipped* and stayed single-underscore,
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>

if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])

# 4. Prefix tool names in message history (tool_use and tool_result blocks)
# 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
block["name"] = _to_oauth_wire_name(block["name"])
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name

Expand Down
14 changes: 14 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,20 @@ def _release_lock() -> None:
force=True,
)

# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
# the completed old session before its details are lost.
_old_sid_for_event = locals().get("old_session_id")
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
"old_session_id": _old_sid_for_event or "",
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)

# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits.
Expand Down
39 changes: 38 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent.session_id, exc,
)

if stored_prompt:
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
return
if stored_prompt:
stored_state = "stale_runtime"
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
)

if conversation_history and stored_state in ("null", "empty"):
# Continuing session whose stored prompt is unusable. The
Expand Down Expand Up @@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)


def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted Model/Provider lines are stale."""

def line_value(label: str) -> str:
prefix = f"{label}:"
value = ""
for line in prompt.splitlines():
if line.startswith(prefix):
value = line[len(prefix):].strip()
return value

stored_model = line_value("Model")
current_model = str(getattr(agent, "model", "") or "").strip()
if stored_model and current_model and stored_model != current_model:
return False

stored_provider = line_value("Provider")
current_provider = str(getattr(agent, "provider", "") or "").strip()
if stored_provider and current_provider and stored_provider != current_provider:
return False

return True


def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3])
Expand Down Expand Up @@ -441,6 +474,7 @@ def run_conversation(
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
Expand All @@ -456,6 +490,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work.

Returns:
Expand All @@ -477,6 +513,7 @@ def run_conversation(
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
Expand Down
87 changes: 84 additions & 3 deletions agent/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class _ReviewRuntimeBinding(NamedTuple):
DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
# deterministic inactivity prune (apply_automatic_transitions) still runs
# whenever the curator is enabled; only the opinionated, aux-model-cost
# consolidation pass is opt-in.
DEFAULT_CONSOLIDATE = False


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -182,6 +187,22 @@ def get_prune_builtins() -> bool:
return bool(cfg.get("prune_builtins", True))


def get_consolidate() -> bool:
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.

OFF by default. When off, a curator run does ONLY the deterministic
inactivity prune (mark stale / archive long-unused skills) and skips the
forked aux-model review entirely — no consolidation, no umbrella-building,
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
LLM pass that merges overlapping skills into class-level umbrellas.

The explicit ``hermes curator run --consolidate`` flag overrides this for
a single invocation regardless of the config value.
"""
cfg = _load_config()
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))


# ---------------------------------------------------------------------------
# Idle / interval check
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1408,25 +1429,38 @@ def run_curator_review(
on_summary: Optional[Callable[[str], None]] = None,
synchronous: bool = False,
dry_run: bool = False,
consolidate: Optional[bool] = None,
) -> Dict[str, Any]:
"""Execute a single curator review pass.

Steps:
1. Apply automatic state transitions (pure, no LLM).
2. If there are agent-created skills, spawn a forked AIAgent that runs
the LLM review prompt against the current candidate list.
2. If consolidation is enabled AND there are agent-created skills, spawn
a forked AIAgent that runs the LLM review prompt against the current
candidate list.
3. Update .curator_state with last_run_at and a one-line summary.
4. Invoke *on_summary* with a user-visible description.

If *synchronous* is True, the LLM review runs in the calling thread; the
default is to spawn a daemon thread so the caller returns immediately.

*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
reads ``curator.consolidate`` from config (OFF by default). Passing
``True``/``False`` overrides the config for this invocation — used by the
``hermes curator run --consolidate`` flag. When consolidation is off, only
the deterministic inactivity prune runs and the forked aux-model review is
skipped entirely (no aux-model cost).

If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
and the LLM review pass is instructed to produce a report only — no
skill_manage mutations, no terminal archive moves. The REPORT.md still
gets written and ``state.last_report_path`` still records it so users
can read what the curator WOULD have done.
can read what the curator WOULD have done. A dry-run also honors
*consolidate*: when consolidation is off, the preview only reports the
deterministic prune candidates.
"""
if consolidate is None:
consolidate = get_consolidate()
start = datetime.now(timezone.utc)
if dry_run:
# Count candidates without mutating state.
Expand Down Expand Up @@ -1489,6 +1523,53 @@ def _llm_pass():
before_report = []
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}

# Consolidation gate. When off (the default), the curator does ONLY the
# deterministic inactivity prune above — no forked aux-model review, no
# umbrella-building, no aux-model cost. Record the run, write a report
# reflecting the prune-only outcome, and return without spawning a fork.
if not consolidate:
final_summary = (
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
)
llm_meta = {
"final": "",
"summary": "skipped (consolidation off)",
"model": "",
"provider": "",
"tool_calls": [],
"error": None,
}
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
state2 = load_state()
state2["last_run_duration_seconds"] = elapsed
state2["last_run_summary"] = final_summary
try:
after_report = skill_usage.agent_created_report()
except Exception:
after_report = []
try:
report_path = _write_run_report(
started_at=start,
elapsed_seconds=elapsed,
auto_counts=counts,
auto_summary=auto_summary,
before_report=before_report,
before_names=before_names,
after_report=after_report,
llm_meta=llm_meta,
)
if report_path is not None:
state2["last_report_path"] = str(report_path)
except Exception as e:
logger.debug("Curator report write failed: %s", e, exc_info=True)
save_state(state2)
if on_summary:
try:
on_summary(f"curator: {final_summary}")
except Exception:
pass
return

llm_meta: Dict[str, Any] = {}
try:
candidate_list = _render_candidate_list()
Expand Down
Loading