diff --git a/.env.example b/.env.example index 6eac3487e9a6..893bda62110a 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,15 @@ # Optional base URL override: # XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1 +# ============================================================================= +# LLM PROVIDER (Upstage Solar) +# ============================================================================= +# Upstage provides access to Upstage Solar models. +# Get your key at: https://console.upstage.ai/api-keys +# UPSTAGE_API_KEY=your_key_here +# Optional base URL override: +# UPSTAGE_BASE_URL=https://api.upstage.ai/v1 + # ============================================================================= # TOOL API KEYS # ============================================================================= diff --git a/agent/account_usage.py b/agent/account_usage.py index 712e57feda4a..9e48b0aac0cb 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -425,15 +425,28 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred ) -def _resolve_codex_usage_url(base_url: str) -> str: +def _codex_backend_urls(base_url: str) -> tuple[str, str, str]: + """Resolve the Codex backend endpoints (usage, reset-credits list, consume). + + Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs + containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything + else uses ``/api/codex/...``. + """ normalized = (base_url or "").strip().rstrip("/") if not normalized: normalized = "https://chatgpt.com/backend-api/codex" if normalized.endswith("/codex"): normalized = normalized[: -len("/codex")] - if "/backend-api" in normalized: - return normalized + "/wham/usage" - return normalized + "/api/codex/usage" + prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") + return ( + prefix + "/usage", + prefix + "/rate-limit-reset-credits", + prefix + "/rate-limit-reset-credits/consume", + ) + + +def _resolve_codex_usage_url(base_url: str) -> str: + return _codex_backend_urls(base_url)[0] def _resolve_codex_usage_credentials( @@ -525,6 +538,14 @@ def _fetch_codex_account_usage( ) ) details: list[str] = [] + reset_credits = payload.get("rate_limit_reset_credits") or {} + banked = reset_credits.get("available_count") + if isinstance(banked, (int, float)) and int(banked) > 0: + count = int(banked) + plural = "s" if count != 1 else "" + details.append( + f"You have {count} reset{plural} banked - use /usage reset to activate" + ) credits = payload.get("credits") or {} if credits.get("has_credits"): balance = credits.get("balance") @@ -542,6 +563,179 @@ def _fetch_codex_account_usage( ) +@dataclass(frozen=True) +class CodexResetRedeemResult: + """Outcome of a `/usage reset` attempt against the Codex backend.""" + + status: str # reset | nothing_to_reset | no_credit | already_redeemed | + # not_exhausted | no_credits_banked | unavailable + message: str + available_count: int = 0 + windows_reset: int = 0 + + @property + def redeemed(self) -> bool: + return self.status == "reset" + + +# Client-side guard threshold: a rate-limit window only counts as exhausted +# when it is fully used. Below this, redeeming a banked reset wastes most of +# its value, so we block and point at --force instead. +_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0 + + +def redeem_codex_reset_credit( + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + force: bool = False, +) -> CodexResetRedeemResult: + """Redeem one banked Codex rate-limit reset credit (`/usage reset`). + + Flow (mirrors the Codex CLI's reset-credits picker, codex-rs + ``backend-client``): + + 1. ``GET .../usage`` — read the current windows + banked credit count. + 2. Guard: zero banked credits → refuse. No window fully used and not + ``force`` → refuse with a warning (a banked reset restores the WHOLE + 5h + weekly allowance; burning it early wastes it). The backend has + the same protection (``nothing_to_reset`` doesn't consume the + credit), but failing fast client-side gives a clearer message. + 3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID + idempotency key (``redeem_request_id``). No ``credit_id`` — the + backend picks the next available credit, exactly like the CLI's + default "Full reset" option. + + Never raises: every failure mode returns a ``CodexResetRedeemResult`` + with a user-renderable message. + """ + import uuid + + try: + token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key) + except Exception: + return CodexResetRedeemResult( + status="unavailable", + message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.", + ) + usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": "codex-cli", + } + if account_id: + headers["ChatGPT-Account-Id"] = account_id + + try: + with httpx.Client(timeout=15.0) as client: + usage_resp = client.get(usage_url, headers=headers) + usage_resp.raise_for_status() + payload = usage_resp.json() or {} + + reset_credits = payload.get("rate_limit_reset_credits") or {} + raw_count = reset_credits.get("available_count") + available = int(raw_count) if isinstance(raw_count, (int, float)) else 0 + if available <= 0: + return CodexResetRedeemResult( + status="no_credits_banked", + message="No banked reset credits on this account — nothing to redeem.", + ) + + rate_limit = payload.get("rate_limit") or {} + worst_used: Optional[float] = None + for key in ("primary_window", "secondary_window"): + used = (rate_limit.get(key) or {}).get("used_percent") + if isinstance(used, (int, float)): + worst_used = max(worst_used or 0.0, float(used)) + exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT + if not exhausted and not force: + usage_note = ( + f"your busiest window is only {worst_used:.0f}% used" + if worst_used is not None + else "your current usage could not be confirmed as exhausted" + ) + plural = "s" if available != 1 else "" + return CodexResetRedeemResult( + status="not_exhausted", + message=( + f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL " + f"5h + weekly limits, so spending it now would waste most of it. " + f"You have {available} reset{plural} banked. " + f"Use `/usage reset --force` to redeem anyway." + ), + available_count=available, + ) + + consume_resp = client.post( + consume_url, + headers={**headers, "Content-Type": "application/json"}, + json={"redeem_request_id": str(uuid.uuid4())}, + ) + consume_resp.raise_for_status() + body = consume_resp.json() or {} + except httpx.HTTPStatusError as exc: + code = exc.response.status_code + if code in (401, 403): + return CodexResetRedeemResult( + status="unavailable", + message=( + "Codex backend rejected the request (HTTP " + f"{code}). Reset credits require ChatGPT-account (OAuth) auth — " + "run `hermes auth` and sign in with your ChatGPT account." + ), + ) + return CodexResetRedeemResult( + status="unavailable", + message=f"Codex backend error (HTTP {code}) — try again shortly.", + ) + except Exception as exc: + return CodexResetRedeemResult( + status="unavailable", + message=f"Could not reach the Codex backend: {exc}", + ) + + code = str(body.get("code", "") or "").strip().lower() + windows_reset = body.get("windows_reset") + windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0 + remaining = max(0, available - 1) + plural = "s" if remaining != 1 else "" + if code == "reset": + return CodexResetRedeemResult( + status="reset", + message=( + f"✅ Reset redeemed — your usage limits have been reset. " + f"{remaining} banked reset{plural} remaining." + ), + available_count=remaining, + windows_reset=windows_reset, + ) + if code == "nothing_to_reset": + return CodexResetRedeemResult( + status="nothing_to_reset", + message=( + "Backend reports nothing to reset — your limits aren't exhausted. " + "The credit was NOT spent." + ), + available_count=available, + ) + if code == "no_credit": + return CodexResetRedeemResult( + status="no_credit", + message="Backend reports no available reset credit on this account.", + ) + if code == "already_redeemed": + return CodexResetRedeemResult( + status="already_redeemed", + message="This redemption was already processed — no additional credit was spent.", + available_count=remaining, + ) + return CodexResetRedeemResult( + status="unavailable", + message=f"Unexpected response from the Codex backend: {body!r}", + ) + + def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]: token = (resolve_anthropic_token() or "").strip() if not token: diff --git a/agent/agent_init.py b/agent/agent_init.py index c5826b4b9475..0c700c279b98 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -434,18 +434,6 @@ def init_agent( agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" - if credential_pool is not None: - try: - from agent.credential_pool import credential_pool_matches_provider - - if not credential_pool_matches_provider( - credential_pool, - agent.provider, - base_url=agent.base_url, - ): - credential_pool = None - except Exception: - credential_pool = None agent._credential_pool = credential_pool agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) @@ -482,6 +470,24 @@ def init_agent( else: agent.api_mode = "chat_completions" + # Credential-pool validation runs AFTER provider auto-detection so + # a pool scoped to e.g. "anthropic" is not rejected when the agent + # was constructed with provider=None and an anthropic.com URL. + # Regression from #63048 which placed this check before the + # URL-based auto-detection block above (fixed #63425). + if credential_pool is not None: + try: + from agent.credential_pool import credential_pool_matches_provider + + if not credential_pool_matches_provider( + credential_pool, + agent.provider, + base_url=agent.base_url, + ): + agent._credential_pool = None + except Exception: + agent._credential_pool = None + # Eagerly warm the transport cache so import errors surface at init, # not mid-conversation. Also validates the api_mode is registered. try: @@ -1311,6 +1317,14 @@ def init_agent( # SQLite session store (optional -- provided by CLI or gateway) agent._session_db = session_db agent._parent_session_id = parent_session_id + # A close flush and the worker's turn-start flush can overlap. The durable + # marker is attached to each in-memory message dict, so its test-and-append + # sequence must be serialized per agent rather than relying on SQLite alone. + agent._session_persist_lock = threading.RLock() + # CLI retains its just-accepted user dict until turn setup can reuse it. + # This preserves the message-local durable marker if close persistence wins + # the race before the agent's normal early turn flush. + agent._pending_cli_user_message = None agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes agent._session_db_created = False # DB row deferred to run_conversation() # Most agents own their session row and should finalize it on close(). diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1cde73419a44..c6ed459e93d9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1305,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool: primary_provider or "?", ) + # ── Restore reasoning_config if it was saved ── + # switch_model saves reasoning_config in _primary_runtime. If the + # snapshot predates that (older sessions), keep the current value. + saved_reasoning = rt.get("reasoning_config") + if saved_reasoning is not None: + agent.reasoning_config = dict(saved_reasoning) + # ── Reset fallback chain for the new turn ── agent._fallback_activated = False agent._fallback_index = 0 @@ -2065,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo api_mode=agent.api_mode, ) + # ── Re-resolve reasoning_config from per-model override ── + # The new model may have a different reasoning_effort override. Re-read + # config so the override takes effect immediately on /model switch — + # resolved through the shared chokepoint (per-model > global; YAML + # boolean False = disabled). + try: + from hermes_constants import resolve_reasoning_config + from hermes_cli.config import load_config as _sm_load_config + + _reasoning_cfg = _sm_load_config() or {} + agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model) + logger.info( + "switch_model: reasoning_config resolved for %s: %s", + agent.model, agent.reasoning_config, + ) + except Exception as _reasoning_err: + logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err) + # ── Invalidate cached system prompt so it rebuilds next turn ── agent._cached_system_prompt = None @@ -2087,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "client_kwargs": dict(agent._client_kwargs), "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, + "reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None, "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e39017be0d79..2b91b9e4d8e6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1286,6 +1286,7 @@ class _AnthropicCompletionsAdapter: model = kwargs.get("model", self._model) tools = kwargs.get("tools") tool_choice = kwargs.get("tool_choice") + reasoning_config = kwargs.get("_reasoning_config") # ZAI's Anthropic-compatible endpoint rejects max_tokens on vision # models (glm-4v-flash etc.) with error code 1210. When the caller # signals this by setting _skip_zai_max_tokens in kwargs, omit it. @@ -1306,12 +1307,25 @@ class _AnthropicCompletionsAdapter: elif choice_type in {"auto", "required", "none"}: normalized_tool_choice = choice_type + # Reasoning priority: explicit per-call reasoning_config (MoA per-slot, + # passed as _reasoning_config by _build_call_kwargs) wins over an + # extra_body.reasoning dict (auxiliary..extra_body config). + # build_anthropic_kwargs translates the config dict into the native + # ``thinking`` field and handles models where thinking is mandatory. + _reasoning_cfg = reasoning_config + if _reasoning_cfg is None: + _eb = kwargs.get("extra_body") + if isinstance(_eb, dict): + _rc = _eb.get("reasoning") + if isinstance(_rc, dict): + _reasoning_cfg = _rc + anthropic_kwargs = build_anthropic_kwargs( model=model, messages=messages, tools=tools, max_tokens=max_tokens, - reasoning_config=None, + reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, ) @@ -3429,6 +3443,7 @@ def _retry_same_provider_sync( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3462,6 +3477,7 @@ def _retry_same_provider_sync( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): @@ -3486,6 +3502,7 @@ async def _retry_same_provider_async( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3519,6 +3536,7 @@ async def _retry_same_provider_async( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): @@ -3638,6 +3656,7 @@ def _call_fallback_candidate_sync( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Optional[Any]: """Call one fallback candidate with stale-credential recovery. @@ -3659,7 +3678,8 @@ def _call_fallback_candidate_sync( fb_label, fb_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, base_url=fb_base) + extra_body=effective_extra_body, reasoning_config=reasoning_config, + base_url=fb_base) try: return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) @@ -3675,6 +3695,7 @@ def _call_fallback_candidate_sync( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( @@ -3707,6 +3728,7 @@ async def _call_fallback_candidate_async( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Optional[Any]: """Async mirror of :func:`_call_fallback_candidate_sync`.""" fb_base = str(getattr(fb_client, "base_url", "") or "") @@ -3714,7 +3736,8 @@ async def _call_fallback_candidate_async( fb_label, fb_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, base_url=fb_base) + extra_body=effective_extra_body, reasoning_config=reasoning_config, + base_url=fb_base) try: return _validate_llm_response( await fb_client.chat.completions.create(**fb_kwargs), task) @@ -3731,6 +3754,7 @@ async def _call_fallback_candidate_async( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( @@ -6141,12 +6165,49 @@ def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float: def _get_task_extra_body(task: str) -> Dict[str, Any]: - """Read auxiliary..extra_body and return a shallow copy when valid.""" + """Read auxiliary..extra_body and return a shallow copy when valid. + + Also folds in ``auxiliary..reasoning_effort`` as an + ``extra_body.reasoning`` config dict ({"enabled": ..., "effort": ...}) + when set. An explicit ``extra_body.reasoning`` in config wins over the + ``reasoning_effort`` shorthand (it is the more specific wire control). + Downstream, each wire already translates ``extra_body.reasoning``: + chat.completions passes it through, the Codex Responses adapter maps it + to top-level ``reasoning``/``include``, and the Anthropic auxiliary + client maps it to ``build_anthropic_kwargs(reasoning_config=...)``. + + MoA tasks are excluded by design: reasoning depth for MoA is a per-slot + setting in the MoA preset (``moa.presets..reference_models[]. + reasoning_effort`` / ``aggregator.reasoning_effort``), not an + auxiliary-task knob — an ensemble-wide value would override the + per-slot ones. + """ task_config = _get_auxiliary_task_config(task) raw = task_config.get("extra_body") - if isinstance(raw, dict): - return dict(raw) - return {} + result = dict(raw) if isinstance(raw, dict) else {} + if "reasoning" not in result: + effort = task_config.get("reasoning_effort") + if effort is not None and effort != "": + if task in ("moa_reference", "moa_aggregator"): + logger.warning( + "auxiliary.%s.reasoning_effort is not supported — MoA " + "reasoning depth is per-slot: set reasoning_effort on the " + "preset's reference_models entries / aggregator instead " + "(moa.presets....). Ignoring.", + task, + ) + return result + from hermes_constants import parse_reasoning_effort + parsed = parse_reasoning_effort(effort) + if parsed is not None: + result["reasoning"] = parsed + else: + logger.warning( + "auxiliary.%s.reasoning_effort %r is not a valid level " + "(none, minimal, low, medium, high, xhigh, max, ultra) — ignoring", + task, effort, + ) + return result # --------------------------------------------------------------------------- @@ -6254,6 +6315,32 @@ def _convert_openai_images_to_anthropic(messages: list) -> list: return converted +_PROFILE_REASONING_KEYS = { + "reasoning", + "reasoning_effort", + "thinking", + "thinking_config", + "thinkingconfig", + "thinking_budget", + "thinkingbudget", + "enable_thinking", + "think", + "verbosity", +} + + +def _contains_profile_reasoning_fields(value: Any) -> bool: + """Return whether a profile payload contains a reasoning wire control.""" + if not isinstance(value, dict): + return False + for key, nested in value.items(): + normalized = str(key).strip().lower() + if normalized in _PROFILE_REASONING_KEYS: + return True + if _contains_profile_reasoning_fields(nested): + return True + return False + def _build_call_kwargs( provider: str, @@ -6264,6 +6351,7 @@ def _build_call_kwargs( tools: Optional[list] = None, timeout: float = 30.0, extra_body: Optional[dict] = None, + reasoning_config: Optional[dict] = None, base_url: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" @@ -6347,13 +6435,89 @@ def _build_call_kwargs( _deduped.append(_t) kwargs["tools"] = _deduped - # Provider-specific extra_body + # Build provider-aware reasoning kwargs through the same profile hooks used + # by the standard chat-completions transport. Some providers require + # top-level controls (Kimi/custom ``reasoning_effort``), others use nested + # body fields (Gemini ``thinking_config``), and OpenRouter/Nous use + # ``extra_body.reasoning``. Profiles are the source of truth for those wire + # shapes. Providers without a reasoning-aware profile retain the generic + # ``extra_body.reasoning`` fallback used by Codex-compatible adapters. + effective_base = base_url or ( + _current_custom_base_url() if provider == "custom" else "" + ) + profile_body: Dict[str, Any] = {} + profile_reasoning_extra: Dict[str, Any] = {} + profile_top_level: Dict[str, Any] = {} + profile_handles_reasoning = False + try: + from providers import get_provider_profile + from providers.base import ProviderProfile + + profile = get_provider_profile(str(provider or "").strip().lower()) + if profile is not None: + profile_body = profile.build_extra_body( + model=model, + base_url=effective_base, + reasoning_config=reasoning_config, + ) or {} + profile_reasoning_extra, profile_top_level = ( + profile.build_api_kwargs_extras( + reasoning_config=reasoning_config, + supports_reasoning=reasoning_config is not None, + model=model, + base_url=effective_base, + ) + ) + profile_reasoning_extra = profile_reasoning_extra or {} + profile_top_level = profile_top_level or {} + profile_handles_reasoning = ( + type(profile).build_api_kwargs_extras + is not ProviderProfile.build_api_kwargs_extras + or _contains_profile_reasoning_fields(profile_body) + or _contains_profile_reasoning_fields(profile_reasoning_extra) + or _contains_profile_reasoning_fields(profile_top_level) + ) + except Exception as exc: + logger.debug( + "_build_call_kwargs: provider profile projection failed for %s: %s", + provider, + exc, + ) + + kwargs.update(profile_top_level) merged_extra = dict(extra_body or {}) - if provider == "nous": - merged_extra.setdefault("tags", []).extend(_nous_portal_tags()) + merged_extra.update(profile_body) + merged_extra.update(profile_reasoning_extra) + if ( + reasoning_config + and isinstance(reasoning_config, dict) + and not profile_handles_reasoning + ): + if reasoning_config.get("enabled") is False: + merged_extra["reasoning"] = {"enabled": False} + else: + effort = reasoning_config.get("effort") or "medium" + merged_extra["reasoning"] = {"enabled": True, "effort": effort} + if provider == "nous" and "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() if merged_extra: kwargs["extra_body"] = merged_extra + # Native Anthropic Messages adapters do not consume ``extra_body``. Carry + # the normalized Hermes reasoning config through a private kwarg so the + # adapter can pass it into build_anthropic_kwargs(), where provider-aware + # thinking/output_config projection lives. Do not expose this private kwarg + # to ordinary OpenAI-compatible SDK clients, which would reject it. + if reasoning_config and isinstance(reasoning_config, dict): + provider_norm = str(provider or "").strip().lower() + effective_base = base_url or "" + if ( + provider_norm == "anthropic" + or _endpoint_speaks_anthropic_messages(effective_base) + or _is_anthropic_compat_endpoint(provider_norm, effective_base) + ): + kwargs["_reasoning_config"] = dict(reasoning_config) + return kwargs @@ -6463,6 +6627,7 @@ def call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + reasoning_config: Optional[dict] = None, api_mode: str = None, stream: bool = False, stream_options: dict = None, @@ -6486,6 +6651,8 @@ def call_llm( tools: Tool definitions (for function calling). timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config). extra_body: Additional request body fields. + reasoning_config: Optional Hermes reasoning config for direct model calls + such as MoA reference/aggregator slots. stream: When True, return the raw SDK streaming iterator instead of a validated complete response. The caller is responsible for consuming chunks (and for any fallback). Used by the MoA aggregator so its @@ -6590,6 +6757,7 @@ def call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=_base_info or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) @@ -6842,6 +7010,7 @@ def call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) # ── Same-provider credential-pool recovery ───────────────────── @@ -6884,6 +7053,7 @@ def call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) except Exception as retry2_err: # The rotated key also hit a quota/auth wall. Mark it @@ -7005,7 +7175,8 @@ def call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # The candidate had a stale/unrefreshable credential and was @@ -7019,7 +7190,8 @@ def call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # All fallback layers exhausted — emit a single user-visible @@ -7114,6 +7286,7 @@ async def async_call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + reasoning_config: Optional[dict] = None, ) -> Any: """Centralized asynchronous LLM call. @@ -7193,6 +7366,7 @@ async def async_call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=_client_base or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) @@ -7389,6 +7563,7 @@ async def async_call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) # ── Same-provider credential-pool recovery (mirrors sync) ───── @@ -7426,6 +7601,7 @@ async def async_call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) except Exception as retry2_err: if (_is_payment_error(retry2_err) or _is_auth_error(retry2_err) @@ -7514,7 +7690,8 @@ async def async_call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # Stale/unrefreshable candidate credential — quarantined; walk @@ -7530,7 +7707,8 @@ async def async_call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # All fallback layers exhausted — warn before re-raising. (#26882) diff --git a/agent/background_review.py b/agent/background_review.py index bf78f679236d..c2ea87bd94e2 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -696,6 +696,19 @@ def _run_review_in_thread( if isinstance(_rt.get("command"), str) and _rt["command"]: _fork_kwargs["acp_command"] = _rt["command"] _fork_kwargs["acp_args"] = _rt.get("args") or [] + # Match parent's reasoning config so the fork's ``thinking`` / + # ``output_config`` are byte-identical in the request body — + # Anthropic's cache key is namespaced by ``thinking`` presence. + # Same-model path only: when routed to a different aux model the + # cache is cold regardless (parity buys nothing) and the parent's + # effort vocabulary may not be valid for the routed model/provider + # (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded + # unclamped; codex_responses passes ``max``/``ultra`` through + # unmapped except on gpt-5.6/xAI). Let the routed fork use + # provider defaults — matching the ``not _routed`` gate on + # _cached_system_prompt below. + if not _routed: + _fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None) review_agent = AIAgent( model=_rt.get("model") or agent.model, max_iterations=16, diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b93dcf661717..dbc946ce899a 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -28,6 +28,7 @@ from typing import Any, Dict, Optional from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH from agent.error_classifier import FailoverReason +from agent.errors import EmptyStreamError from agent.gemini_native_adapter import is_native_gemini_base_url from agent.model_metadata import is_local_endpoint from agent.message_sanitization import ( @@ -1635,6 +1636,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool api_mode=agent.api_mode, ) + # Re-resolve reasoning_config for the new fallback model (Closes #21256). + # Shared chokepoint: per-model override > global reasoning_effort + # (YAML boolean False = disabled). Wrapped in try/except because a + # config load failure must not kill the swap. + try: + from hermes_cli.config import load_config + from hermes_constants import resolve_reasoning_config + + agent.reasoning_config = resolve_reasoning_config( + load_config() or {}, agent.model + ) + logger.info( + "Fallback %s: reasoning_config resolved: %s", + agent.model, agent.reasoning_config, + ) + except Exception as _reasoning_err: + logger.debug( + "Failed to resolve reasoning_config for fallback %s; keeping current: %s", + agent.model, _reasoning_err, + ) + # Keep whatever reasoning_config was active — don't break the fallback swap. + # Keep the prompt's self-identity in sync with the model actually # answering, so "what model are you?" doesn't report the primary. rewrite_prompt_model_identity(agent, fb_model, fb_provider) @@ -2511,7 +2534,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= and not reasoning_parts and not tool_calls_acc ): - raise RuntimeError( + raise EmptyStreamError( "Provider returned an empty stream with no finish_reason " "(possible upstream error or malformed SSE response)." ) @@ -2600,6 +2623,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= works unchanged. """ has_tool_use = False + # Zero-event guard parity with the chat_completions path: track + # whether the provider delivered ANY stream event. On an eventless + # stream the real Anthropic SDK's get_final_message() raises + # AssertionError (no message_start ⇒ no final-message snapshot); + # OpenAI-compat shims may instead fabricate a contentless Message + # with no stop_reason, or return None under ``python -O`` (assert + # stripped). Every one of those shapes is normalized below to + # EmptyStreamError so the shared _call() retry loop treats it as + # transient instead of surfacing a raw AssertionError or a + # fabricated "successful" empty turn. + saw_stream_event = False # Reset stale-stream timer for this attempt last_chunk_time["t"] = time.time() @@ -2627,6 +2661,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= except Exception: pass for event in stream: + saw_stream_event = True # Update stale-stream timer on every event so the # outer poll loop knows data is flowing. Without # this, the detector kills healthy long-running @@ -2687,7 +2722,38 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # this return value is discarded anyway. if agent._interrupt_requested: return None - return stream.get_final_message() + # Zero-event guard (parity with the chat_completions zero-chunk + # guard above). Real SDK: an eventless stream has no + # message_start, so get_final_message() raises AssertionError + # (final-message snapshot is None) — normalize that to + # EmptyStreamError so it gets the transient retry budget + # instead of surfacing raw. + try: + _final_message = stream.get_final_message() + except AssertionError: + if not saw_stream_event: + raise EmptyStreamError( + "Provider returned an empty stream with no events " + "(possible upstream error or malformed event stream)." + ) from None + raise + # Shim variants of the same failure: an OpenAI-compat adapter + # may fabricate a contentless Message with no stop_reason, or + # return None where the SDK assert would have fired (e.g. + # ``python -O``). A real completed response always carries a + # stop_reason, so this cannot fire on legitimate turns. + if not saw_stream_event and ( + _final_message is None + or ( + not getattr(_final_message, "content", None) + and getattr(_final_message, "stop_reason", None) is None + ) + ): + raise EmptyStreamError( + "Provider returned an empty stream with no stop_reason " + "(possible upstream error or malformed event stream)." + ) + return _final_message def _call(): import httpx as _httpx @@ -2734,6 +2800,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError) ) _is_stream_parse_err = agent._is_provider_stream_parse_error(e) + _is_empty_stream = isinstance(e, EmptyStreamError) # If the stream died AFTER some tokens were delivered: # normally we don't retry (the user already saw text, @@ -2876,7 +2943,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= for phrase in _SSE_CONN_PHRASES ) - if _is_timeout or _is_conn_err or _is_sse_conn_err or _is_stream_parse_err: + if ( + _is_timeout + or _is_conn_err + or _is_sse_conn_err + or _is_stream_parse_err + or _is_empty_stream + ): # Transient network / timeout error. Retry the # streaming request with a fresh connection first. if _stream_attempt < _max_stream_retries: @@ -2919,17 +2992,32 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= mid_tool_call=False, diag=request_client_holder.get("diag"), ) - agent._buffer_status( - "❌ Provider returned malformed streaming data after " - f"{_max_stream_retries + 1} attempts. " - "The provider may be experiencing issues — " - "try again in a moment." - if _is_stream_parse_err else - "❌ Connection to provider failed after " - f"{_max_stream_retries + 1} attempts. " - "The provider may be experiencing issues — " - "try again in a moment." - ) + if _is_stream_parse_err: + _exhausted_msg = ( + "❌ Provider returned malformed streaming data after " + f"{_max_stream_retries + 1} attempts. " + "The provider may be experiencing issues — " + "try again in a moment." + ) + elif _is_empty_stream: + # The connection SUCCEEDED (stream opened) but the + # provider sent no chunks — saying "connection + # failed" here sends users chasing network issues + # when the problem is the provider/endpoint. + _exhausted_msg = ( + "❌ Provider returned an empty response stream " + f"after {_max_stream_retries + 1} attempts. " + "The provider may be experiencing issues — " + "try again in a moment." + ) + else: + _exhausted_msg = ( + "❌ Connection to provider failed after " + f"{_max_stream_retries + 1} attempts. " + "The provider may be experiencing issues — " + "try again in a moment." + ) + agent._buffer_status(_exhausted_msg) else: _err_lower = str(e).lower() _is_stream_unsupported = ( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 75c07540d885..5a81d2f7793e 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -194,7 +194,6 @@ def _is_nous_inference_route(provider: str, base_url: str) -> bool: base = str(base_url or "") return ( base_url_host_matches(base, "inference-api.nousresearch.com") - or base_url_host_matches(base, "inference.nousresearch.com") ) @@ -522,12 +521,12 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt): def run_conversation( agent, - user_message: str, + user_message: Any, system_message: str = None, conversation_history: List[Dict[str, Any]] = None, task_id: str = None, stream_callback: Optional[callable] = None, - persist_user_message: Optional[str] = None, + persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: @@ -857,10 +856,19 @@ def run_conversation( if moa_config: try: + from agent.message_content import flatten_message_text as _flatten_mt from agent.moa_loop import _preset_temperature, aggregate_moa_context _moa_context = aggregate_moa_context( - user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message), + user_prompt=( + original_user_message + if isinstance(original_user_message, str) + # Multimodal / decorated content list: extract the + # visible text instead of str()-ing a Python repr of + # the parts (which would leak base64 image payloads + # into the aggregator prompt). + else _flatten_mt(original_user_message) + ), api_messages=api_messages, reference_models=moa_config.get("reference_models") or [], aggregator=moa_config.get("aggregator") or {}, @@ -874,6 +882,14 @@ def run_conversation( _base = _msg.get("content", "") if isinstance(_base, str): _msg["content"] = _base + "\n\n" + _moa_context + elif isinstance(_base, list): + # Multimodal user turn (text + image parts): + # append the MoA context as a trailing text + # part instead of silently dropping it. + _msg["content"] = [ + *_base, + {"type": "text", "text": "\n\n" + _moa_context}, + ] break except Exception as _moa_exc: logger.warning("MoA context aggregation failed: %s", _moa_exc) @@ -3061,14 +3077,24 @@ def run_conversation( # (``/new``), switch to a larger-context model, or reduce # attachments. Forced compaction via ``/compress`` # (``force=True``) is unaffected — it never reaches this loop. + # + # Output-cap errors (max_tokens too large) are NOT input + # overflow — the recovery is a max_tokens-only retry that + # does not require compression. Exempt them from this guard + # so the retry still fires even when compression is disabled. _overflow_reasons = { FailoverReason.long_context_tier, FailoverReason.payload_too_large, FailoverReason.context_overflow, } + _is_output_cap_error = ( + is_output_cap_error(error_msg) + or parse_available_output_tokens_from_error(error_msg) is not None + ) if ( classified.reason in _overflow_reasons and not getattr(agent, "compression_enabled", True) + and not _is_output_cap_error ): agent._flush_status_buffer() agent._vprint( @@ -3472,15 +3498,33 @@ def run_conversation( # context_length = total window (input + output combined). available_out = parse_available_output_tokens_from_error(error_msg) if available_out is not None: - # Error is purely about the output cap being too large. - # Cap output to the available space and retry without - # touching context_length or triggering compression. - safe_out = max(1, available_out - 64) # small safety margin + # This is an output-cap error, not input overflow. + # The provider's available_tokens is the authoritative + # cap for the failed request, so keep it as an upper + # bound. Also estimate the current API request shape + # (system prompt, injected context, tool schemas) because + # Hermes may add API-only content not present in persisted + # messages. Use the smaller budget and apply a small + # safety margin. Do not alter context_length. + request_input_estimate = estimate_request_tokens_rough( + api_messages, tools=agent.tools or None, + ) + local_available_out = old_ctx - request_input_estimate + if local_available_out > 0: + safe_out = max(1, min(available_out, local_available_out) - 64) + else: + # The rough local estimate can overshoot the real + # request size. Fall back to the provider-reported + # budget, which is authoritative for the failed + # request. + safe_out = max(1, available_out - 64) agent._ephemeral_max_output_tokens = safe_out agent._buffer_vprint( f"⚠️ Output cap too large for current prompt — " f"retrying with max_tokens={safe_out:,} " - f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})" + f"(provider_available={available_out:,}, " + f"estimated_request_tokens={request_input_estimate:,}; " + f"context_length unchanged at {old_ctx:,})" ) # Still count against compression_attempts so we don't # loop forever if the error keeps recurring. @@ -4450,7 +4494,9 @@ def run_conversation( if agent.verbose_logging: for tc in assistant_message.tool_calls: - logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...") + raw_args = tc.function.arguments + args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200] + logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview) # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating @@ -4631,11 +4677,37 @@ def run_conversation( assistant_msg = agent._build_assistant_message(assistant_message, finish_reason) + turn_content = assistant_message.content or "" + + # Classify tools in this turn to determine if they are all housekeeping. + # This classification is needed regardless of whether the turn has visible content, + # because a substantive tool-only turn must invalidate any older housekeeping fallback. + _HOUSEKEEPING_TOOLS = frozenset({ + "memory", "todo", "skill_manage", "session_search", + }) + _all_housekeeping = all( + tc.function.name in _HOUSEKEEPING_TOOLS + for tc in assistant_message.tool_calls + ) + + # If this turn has substantive tools (non-housekeeping), clear any older fallback. + # Prevents a two-turn-old housekeeping narration from being treated as if it belonged + # to the immediately preceding substantive tool turn. + if assistant_message.tool_calls and not _all_housekeeping: + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + # Also clear the mute flag: a prior housekeeping turn may + # have set _mute_post_response (line ~4667), and the + # substantive tools in THIS turn should produce visible + # progress output. Without this reset, _vprint suppresses + # tool progress until the no-tool-call branch clears it at + # line ~4834 — after all tools have finished. + agent._mute_post_response = False + # If this turn has both content AND tool_calls, capture the content # as a fallback final response. Common pattern: model delivers its # answer and calls memory/skill tools as a side-effect in the same # turn. If the follow-up turn after tools is empty, we use this. - turn_content = assistant_message.content or "" if turn_content and agent._has_content_after_think_block(turn_content): agent._last_content_with_tools = turn_content # Only mute subsequent output when EVERY tool call in @@ -4643,13 +4715,6 @@ def run_conversation( # skill_manage, etc.). If any substantive tool is present # (search_files, read_file, write_file, terminal, ...), # keep output visible so the user sees progress. - _HOUSEKEEPING_TOOLS = frozenset({ - "memory", "todo", "skill_manage", "session_search", - }) - _all_housekeeping = all( - tc.function.name in _HOUSEKEEPING_TOOLS - for tc in assistant_message.tool_calls - ) agent._last_content_tools_all_housekeeping = _all_housekeeping if _all_housekeeping and agent._has_stream_consumers(): agent._mute_post_response = True @@ -5265,6 +5330,53 @@ def run_conversation( final_response = None continue + # ── Kanban worker terminal-tool stop guard ───────────── + # Workers must end with kanban_complete / kanban_block. + # Models sometimes narrate the next step ("Let me write the + # report") and stop with finish_reason=stop — a clean exit + # that the dispatcher records as protocol_violation. Nudge + # once or twice before allowing that exit. + try: + from agent.kanban_stop import build_kanban_stop_nudge + + _kanban_nudge = build_kanban_stop_nudge( + messages=messages, + attempts=getattr(agent, "_kanban_stop_nudges", 0), + ) + except Exception: + logger.debug("kanban stop-loop check failed", exc_info=True) + _kanban_nudge = None + + if _kanban_nudge: + agent._kanban_stop_nudges = ( + getattr(agent, "_kanban_stop_nudges", 0) + 1 + ) + final_msg["finish_reason"] = "kanban_terminal_required" + final_msg["_kanban_stop_synthetic"] = True + messages.append(final_msg) + messages.append({ + "role": "user", + "content": _kanban_nudge, + "_kanban_stop_synthetic": True, + }) + agent._session_messages = messages + logger.info( + "kanban stop-loop nudge issued (attempt %d) task=%s", + agent._kanban_stop_nudges, + os.environ.get("HERMES_KANBAN_TASK", ""), + ) + agent._emit_status( + "⚠️ Kanban worker tried to exit without " + "kanban_complete/kanban_block — nudging to finish" + ) + # Same finalizer contract as verify-on-stop: clear + # final_response while continuing so a later budget + # exhaustion path does not treat the narrated stop as + # a completed answer. + _pending_verification_response = final_response + final_response = None + continue + messages.append(final_msg) _turn_exit_reason = f"text_response(finish_reason={finish_reason})" diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index ce3ec2c5c400..5e095af3902b 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -26,7 +26,7 @@ from openai.types.chat.chat_completion_message_tool_call import ( Function, ) -from agent.file_safety import get_read_block_error, is_write_denied +from agent.file_safety import get_read_block_error, get_write_denied_error from agent.redact import redact_sensitive_text from tools.environments.local import hermes_subprocess_env @@ -727,10 +727,9 @@ class CopilotACPClient: elif method == "fs/write_text_file": try: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) - if is_write_denied(str(path)): - raise PermissionError( - f"Write denied: '{path}' is a protected system/credential file." - ) + denied = get_write_denied_error(str(path)) + if denied: + raise PermissionError(denied) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(str(params.get("content") or "")) response = { diff --git a/agent/errors.py b/agent/errors.py index abedd83d29ff..89afcd3b4c8a 100644 --- a/agent/errors.py +++ b/agent/errors.py @@ -1,3 +1,9 @@ class SSLConfigurationError(Exception): """Raised when SSL/TLS certificate bundle configuration fails.""" pass + + +class EmptyStreamError(RuntimeError): + """Raised when a provider closes a stream without yielding a response.""" + + pass diff --git a/agent/file_safety.py b/agent/file_safety.py index d7e20ee5f0b9..2f4b3666e28d 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]: return roots -def is_write_denied(path: str) -> bool: - """Return True if path is blocked by the write denylist or safe root.""" +def _classify_write_denial(path: str) -> Optional[str]: + """Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed.""" home = os.path.realpath(os.path.expanduser("~")) resolved = os.path.realpath(os.path.expanduser(str(path))) if resolved in build_write_denied_paths(home): - return True + return "credential" for prefix in build_write_denied_prefixes(home): if resolved.startswith(prefix): - return True + return "credential" mcp_tokens_dir_name = "mcp-tokens" @@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool: try: mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name)) if resolved == mcp_real or resolved.startswith(mcp_real + os.sep): - return True + return "credential" except Exception: pass try: pairing_real = os.path.realpath(os.path.join(base_real, "pairing")) if resolved == pairing_real or resolved.startswith(pairing_real + os.sep): - return True + return "credential" except Exception: pass @@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool: allowed = True break if not allowed: - return True + return "safe_root" - return False + return None + + +def is_write_denied(path: str) -> bool: + """Return True if path is blocked by the write denylist or safe root.""" + return _classify_write_denial(path) is not None + + +def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]: + """Return a user/model-facing error when writes to ``path`` are blocked.""" + denial = _classify_write_denial(path) + if denial is None: + return None + if denial == "safe_root": + roots_display = os.pathsep.join(sorted(get_safe_write_roots())) + return ( + f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT " + f"({roots_display}). Unset the variable or add this path's directory prefix." + ) + return f"{verb} denied: '{path}' is a protected system/credential file." # Common secret-bearing project-local environment file basenames. diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 9d3b1eb324d5..1c25f1e6cf0a 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -32,6 +32,13 @@ from agent.gemini_schema import sanitize_gemini_tool_parameters logger = logging.getLogger(__name__) +try: + import hermes_cli as _hermes_cli + + _HERMES_VERSION = str(_hermes_cli.__version__) +except Exception: + _HERMES_VERSION = "0.0.0" + DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" # Published max output-token ceiling shared by every current Gemini text model @@ -99,7 +106,10 @@ def probe_gemini_tier( url, params={"key": key}, json=payload, - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", + }, ) except Exception as exc: logger.debug("probe_gemini_tier: network error: %s", exc) @@ -901,7 +911,11 @@ class GeminiNativeClient: "Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, - "User-Agent": "hermes-agent (gemini-native)", + # Include Hermes client context following Gemini's partner + # integration guidance. + # See https://ai.google.dev/gemini-api/docs/partner-integration + "User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)", + "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", } headers.update(self._default_headers) return headers diff --git a/agent/kanban_stop.py b/agent/kanban_stop.py new file mode 100644 index 000000000000..e7c2eae828a6 --- /dev/null +++ b/agent/kanban_stop.py @@ -0,0 +1,108 @@ +"""Turn-end guard for kanban workers. + +Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models +(especially GLM / Qwen families) sometimes narrate the next step +("Let me write the report now") and stop with ``finish_reason=stop`` and no +tool calls. Hermes treats that as a clean exit → ``rc=0`` → dispatcher +``protocol_violation``. + +This module is policy-only: when a kanban worker tries to finish without a +terminal board tool, return a bounded synthetic nudge so the conversation +loop continues instead of exiting. +""" + +from __future__ import annotations + +import os +from typing import Any, Iterable, Optional + + +_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"}) + +_DEFAULT_MAX_ATTEMPTS = 2 + + +def kanban_stop_nudge_enabled() -> bool: + """Return whether the kanban stop-guard is active for this process. + + On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless + ``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it. + """ + env = os.environ.get("HERMES_KANBAN_STOP_NUDGE") + if env is not None and env.strip().lower() in {"0", "false", "no", "off"}: + return False + task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip() + return bool(task) + + +def _tool_call_name(tc: Any) -> str: + if isinstance(tc, dict): + fn = tc.get("function") + if isinstance(fn, dict): + return str(fn.get("name") or "") + return str(tc.get("name") or "") + fn = getattr(tc, "function", None) + if fn is not None: + return str(getattr(fn, "name", "") or "") + return str(getattr(tc, "name", "") or "") + + +def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool: + """True if this conversation already invoked a terminal kanban tool.""" + if not messages: + return False + for msg in messages: + if not isinstance(msg, dict): + continue + role = msg.get("role") + if role == "assistant": + for tc in msg.get("tool_calls") or []: + if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS: + return True + elif role == "tool": + name = str(msg.get("name") or "") + if name in _TERMINAL_KANBAN_TOOLS: + return True + return False + + +def build_kanban_stop_nudge( + *, + messages: Iterable[dict] | None = None, + attempts: int = 0, + max_attempts: int = _DEFAULT_MAX_ATTEMPTS, + task_id: Optional[str] = None, +) -> Optional[str]: + """Return a synthetic follow-up when a kanban worker exits without a terminal tool. + + Returns ``None`` when the guard should not fire (not a kanban worker, + already completed/blocked, or nudge budget exhausted). + """ + if not kanban_stop_nudge_enabled(): + return None + if attempts >= max_attempts: + return None + if session_called_kanban_terminal(messages): + return None + + tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task" + return ( + "[System: You are a Hermes kanban worker. A plain-text reply is NOT a " + "terminal state for the board.\n\n" + f"Task `{tid}` is still `running`. Ending now without a board tool " + "causes a protocol violation (clean exit with no " + "`kanban_complete` / `kanban_block`).\n\n" + "Do this immediately in your next response — do not narrate intent:\n" + "1. Finish any remaining deliverable (write the required file(s) now).\n" + "2. Call `kanban_complete(summary=..., artifacts=[...])` if the work " + "is done, OR `kanban_block(reason=...)` if you are blocked.\n\n" + "Never end a turn with only a promise of future action. Repeated " + "protocol violations will block this task and require manual intervention.]" + ) + + +__all__ = [ + "build_kanban_stop_nudge", + "kanban_stop_nudge_enabled", + "session_called_kanban_terminal", +] diff --git a/agent/moa_loop.py b/agent/moa_loop.py index cd7fa3ea4ea2..314aaa8b0295 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -14,6 +14,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any from agent.auxiliary_client import call_llm +from agent.message_content import flatten_message_text from agent.transports import get_transport logger = logging.getLogger(__name__) @@ -119,11 +120,24 @@ _REFERENCE_SYSTEM_PROMPT = ( -def _slot_label(slot: dict[str, str]) -> str: - return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" +def _slot_label(slot: dict[str, Any]) -> str: + label = f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" + effort = str(slot.get("reasoning_effort") or "").strip() + return f"{label}[reasoning={effort}]" if effort else label -def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]: +def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None: + """Translate optional per-MoA-slot reasoning_effort into runtime config.""" + effort = slot.get("reasoning_effort") + try: + from hermes_constants import parse_reasoning_effort + + return parse_reasoning_effort(effort) + except Exception: # pragma: no cover - defensive; bad config must not break MoA + return None + + +def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]: """Resolve a reference/aggregator slot to real runtime call kwargs. A MoA slot is just a model selection — it must be called the same way any @@ -275,6 +289,7 @@ def _run_reference( messages=messages, temperature=temperature, max_tokens=max_tokens, + reasoning_config=_slot_reasoning_config(slot), **runtime, ) usage = CanonicalUsage() @@ -470,13 +485,52 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: for msg in messages: role = msg.get("role") content = msg.get("content") - text = content if isinstance(content, str) else "" + # Flatten structured content (lists of parts) to visible text. Content + # arrives as a list — not a string — in two common cases: + # 1. Anthropic prompt-cache decoration: conversation_loop runs + # apply_anthropic_cache_control BEFORE the MoA facade, converting + # string content to [{"type": "text", "text": ..., "cache_control": + # ...}]. A str-only read here flattened the user's ENTIRE prompt to + # "" — Claude references then 400'd ("messages: at least one + # message is required") while tolerant models answered "no user + # request is present". + # 2. Multimodal turns (pasted image → text + image_url parts) and + # multimodal tool results (screenshots). + # flatten_message_text extracts the text parts and skips image parts, + # and returns strings unchanged — so a decorated and an undecorated + # transcript produce a byte-identical advisory view (which keeps the + # advisory prefix stable across iterations for advisor prompt caching). + text = flatten_message_text(content) if role == "system": continue if role == "user": - if text.strip(): - last_user_content = text + if not text.strip() and isinstance(content, list) and content: + # Structured content with no extractable text (e.g. an + # image-only turn). Emitting an empty user message would be + # dropped/rejected by strict providers (Anthropic 400s on + # empty text blocks — the original "closed" preset failure + # mode), and silently skipping the turn would break + # user/assistant alternation in the advisory view. Substitute + # a placeholder so the reference knows a non-text turn + # happened. Only structured content qualifies — an empty or + # whitespace-only STRING turn carries nothing and is dropped + # below instead. + text = "[user sent non-text content (e.g. an image attachment)]" + if not text.strip(): + # Genuinely empty user turn (content="" / None). It carries + # nothing advisory, and strict providers (Kimi/Moonshot, ZAI, + # and others that enforce non-empty user content) reject it + # with 400 "message ... with role 'user' must not be empty" — + # the same way the assistant branch below drops turns with no + # parts. Lenient providers (DeepSeek) accept the empty turn, + # which is why a MoA fan-out would fail on one reference and + # pass on another for the identical rendered view. The + # advisory view is already not strictly alternating (adjacent + # assistant turns occur in every tool loop), so dropping a + # contentless turn is safe. + continue + last_user_content = text rendered.append({"role": "user", "content": text}) elif role == "assistant": parts: list[str] = [] @@ -517,8 +571,10 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: if last_user_content is not None: return [{"role": "user", "content": last_user_content}] for msg in reversed(messages): - if msg.get("role") == "user" and isinstance(msg.get("content"), str): - return [{"role": "user", "content": msg["content"]}] + if msg.get("role") == "user": + fallback_text = flatten_message_text(msg.get("content")) + if fallback_text.strip(): + return [{"role": "user", "content": fallback_text}] return rendered @@ -638,6 +694,7 @@ def aggregate_moa_context( messages=agg_messages, temperature=aggregator_temperature, max_tokens=max_tokens, + reasoning_config=_slot_reasoning_config(aggregator), **agg_runtime, ) synthesis = _extract_text(response) @@ -671,13 +728,28 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str Appending at the very end keeps the ``[system][task][tool-history]`` prefix stable and cache-reusable (only the new block re-prefills), and gives the aggregator the references with recency. Merge into the last message only when - it is already a trailing string ``user`` turn (plain chat — still at the end). + it is already a trailing ``user`` turn (plain chat — still at the end). + + A trailing user turn's content may be a STRING or a LIST of content parts — + Anthropic prompt-cache decoration (which runs before the MoA facade) + converts string content to ``[{"type": "text", ..., "cache_control": ...}]``, + and multimodal turns are lists natively. Both shapes are merged in place: + appending a new text part AFTER the cache_control-marked part keeps the + cached prefix byte-stable (the marker still terminates it) while the + turn-varying guidance rides outside the cached span. Appending a SEPARATE + user message here instead would produce two consecutive user turns — + strict providers reject that. """ last = agg_messages[-1] if agg_messages else None - if last is not None and last.get("role") == "user" and isinstance(last.get("content"), str): - last["content"] = last["content"] + "\n\n" + guidance - else: - agg_messages.append({"role": "user", "content": guidance}) + if last is not None and last.get("role") == "user": + last_content = last.get("content") + if isinstance(last_content, str): + last["content"] = last_content + "\n\n" + guidance + return + if isinstance(last_content, list): + last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}] + return + agg_messages.append({"role": "user", "content": guidance}) class MoAChatCompletions: @@ -1017,6 +1089,7 @@ class MoAChatCompletions: max_tokens=agg_kwargs.get("max_tokens"), tools=agg_kwargs.get("tools"), extra_body=agg_kwargs.get("extra_body"), + reasoning_config=_slot_reasoning_config(aggregator), **stream_kwargs, **_slot_runtime(aggregator), ) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 1e9b66ef3090..03c05177b1f6 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str: # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", + "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra", "opencode-zen", "opencode-go", "kilocode", "alibaba", "novita", "qwen-oauth", "xiaomi", @@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ # Common aliases "google", "google-gemini", "google-ai-studio", "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", - "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", + "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra", "ollama", "stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", @@ -318,6 +318,16 @@ DEFAULT_CONTEXT_LENGTHS = { "grok": 131072, # catch-all (grok-beta, unknown grok-*) # Kimi "kimi": 262144, + # Upstage Solar — api.upstage.ai/v1/models does not return context_length, + # so these fallbacks keep token budgeting / compression from probing down + # to the 128k default. Ids are matched longest-first, so dated variants + # (e.g. solar-pro3-250127) resolve via their family prefix. + # Sources: Solar Pro 3 = 128K, Solar Pro 2 = 64K, Solar Mini = 32K, + # Solar Open 2 = 256K. + "solar-open2": 262144, # 256K + "solar-pro3": 131072, + "solar-pro2": 65536, + "solar-mini": 32768, # Tencent — Hy3 Preview (Hunyuan) with 256K context window. # OpenRouter live metadata reports 262144 (256 × 1024); align the # static fallback so cache and offline both agree (issue #22268). diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b5b2b58c3621..957b3f5fa1ce 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -659,19 +659,7 @@ PLATFORM_HINTS = { "Standard Markdown is automatically converted to Telegram formatting. " "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, " "`inline code`, ```code blocks```, [links](url), and ## headers. " - "Telegram now supports rich Markdown, so lean into it: whenever it " - "makes the answer clearer or easier to scan, actively reach for real " - "Markdown tables (pipe `| col | col |` syntax), bullet and numbered " - "lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, " - "collapsible details, footnotes/references, math/formulas (`$...$`, " - "`$$...$$`), underline, subscript/superscript, marked (highlighted) " - "text, and anchors. Default to structured formatting over dense " - "paragraphs for any comparison, set of steps, key/value summary, or " - "tabular data. Prefer real Markdown tables and task lists over " - "hand-built bullet substitutes when presenting structured data; these " - "degrade gracefully (tables become readable bullet groups) when rich " - "rendering is unavailable, but advanced constructs like math and " - "collapsible details may render as plain source text in that case. " + "Prefer bullet lists and labeled key:value pairs for structured data. " "You can send media files natively: to deliver a file to the user, " "include MEDIA:/absolute/path/to/file in your response. Images " "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice " @@ -865,6 +853,27 @@ PLATFORM_HINTS = { ), } +# Telegram rich-messages extension — only injected when the user has opted in +# to ``platforms.telegram.extra.rich_messages: true``. The base +# PLATFORM_HINTS["telegram"] covers MarkdownV2-compatible constructs; this +# extension adds the Bot API 10.1 rich-Markdown guidance (tables, task lists, +# collapsible details, math, etc.). +TELEGRAM_RICH_MESSAGES_HINT = ( + "Telegram now supports rich Markdown, so lean into it: whenever it " + "makes the answer clearer or easier to scan, actively reach for real " + "Markdown tables (pipe `| col | col |` syntax), bullet and numbered " + "lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, " + "collapsible details, footnotes/references, math/formulas (`$...$`, " + "`$$...$$`), underline, subscript/superscript, marked (highlighted) " + "text, and anchors. Default to structured formatting over dense " + "paragraphs for any comparison, set of steps, key/value summary, or " + "tabular data. Prefer real Markdown tables and task lists over " + "hand-built bullet substitutes when presenting structured data; these " + "degrade gracefully (tables become readable bullet groups) when rich " + "rendering is unavailable, but advanced constructs like math and " + "collapsible details may render as plain source text in that case. " +) + # --------------------------------------------------------------------------- # Environment hints — execution-environment awareness for the agent. # Unlike PLATFORM_HINTS (which describe the messaging channel), these describe diff --git a/agent/skill_commands.py b/agent/skill_commands.py index f4f470c4c014..11d425b91a52 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -329,6 +329,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: try: from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files + from hermes_cli.commands import resolve_command disabled = _get_disabled_skill_names() seen_names: set = set() @@ -374,7 +375,32 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-') if not cmd_name: continue - _skill_commands[f"/{cmd_name}"] = { + # Skip if this skill's auto-generated /command collides + # with a core Hermes slash command (name or alias). The + # skill remains fully loadable via /skill . + # Uses resolve_command() so aliases and case variants are + # covered without maintaining a separate cache. + if resolve_command(cmd_name) is not None: + logger.warning( + "Skill %r generates slash command '/%s' which " + "collides with a core Hermes command; skipping " + "auto-registration. Use '/skill %s' instead.", + name, cmd_name, name, + ) + continue + # Dedup on the resolved slug, not just the raw name: two + # distinct frontmatter names can normalize to the same + # slug (e.g. "git_helper" vs "git-helper"). First-wins + # preserves local-before-external precedence. + cmd_key = f"/{cmd_name}" + if cmd_key in _skill_commands: + logger.warning( + "Skill %r maps to slash command %s already claimed " + "by %r; keeping the first and skipping this one.", + name, cmd_key, _skill_commands[cmd_key]["name"], + ) + continue + _skill_commands[cmd_key] = { "name": name, "description": description or f"Invoke the {name} skill", "skill_md_path": str(skill_md), diff --git a/agent/system_prompt.py b/agent/system_prompt.py index ea33df54a0d0..17959af3c510 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -40,6 +40,7 @@ from agent.prompt_builder import ( SKILLS_GUIDANCE, STEER_CHANNEL_NOTE, TASK_COMPLETION_GUIDANCE, + TELEGRAM_RICH_MESSAGES_HINT, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, drain_truncation_warnings, @@ -429,6 +430,20 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) except Exception: pass + # For Telegram: append the rich-messages extension only when the user has + # opted in to ``platforms.telegram.extra.rich_messages: true``. The base + # hint covers MarkdownV2-compatible constructs; the extension adds Bot API + # 10.1 guidance (tables, task lists, math, collapsible details, etc.). + if platform_key == "telegram" and _default_hint: + try: + from hermes_cli.config import load_config_readonly + _cfg = load_config_readonly() + _tg_extra = ((_cfg.get("platforms") or {}).get("telegram") or {}).get("extra") or {} + if _tg_extra.get("rich_messages"): + _default_hint = _default_hint.rstrip() + " " + TELEGRAM_RICH_MESSAGES_HINT + except Exception: + pass # Config read failure — fall back to base hint only + _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint) if platform_key == "tui" and _effective_hint: _effective_hint = _tui_embedded_pane_clarifier(_effective_hint) diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 1b6cae98ac66..911cf5fe639b 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -102,50 +102,119 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool: return False -def _should_parallelize_tool_batch(tool_calls) -> bool: - """Return True when a tool-call batch is safe to run concurrently.""" - if len(tool_calls) <= 1: - return False +def _plan_tool_batch_segments(tool_calls) -> List[tuple]: + """Split a tool-call batch into ordered ``(kind, calls)`` segments. - tool_names = [tc.function.name for tc in tool_calls] - if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names): - return False + ``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe + calls) or ``"sequential"`` (one or more barrier calls that must run + in-order on the sequential path). Segments preserve the model's + original call order exactly — a later call never crosses an earlier + barrier — so tool-result ordering and side-effect boundaries are + identical to fully-sequential execution. The per-call safety rules + are the same ones the old all-or-nothing gate applied to the whole + batch: + * ``_NEVER_PARALLEL_TOOLS`` (interactive tools) → barrier. + * Unparseable / non-dict arguments → barrier. + * Path-scoped tools (``read_file``/``write_file``/``patch``) join a + parallel run only when their target path does not overlap another + path already reserved in the same run; an overlap closes the run so + the conflicting call starts a NEW run after the first completes. + * Anything not in ``_PARALLEL_SAFE_TOOLS`` and not an opted-in MCP + tool → barrier. + + Parallel runs shorter than two calls are demoted to sequential (no + concurrency win, and the sequential executor owns the richer inline + dispatch), and adjacent sequential segments are merged. + """ + segments: list[list] = [] # [kind, calls] pairs, normalized to tuples on return + current: list = [] reserved_paths: list[Path] = [] + + def _close_parallel() -> None: + nonlocal current, reserved_paths + if current: + segments.append(["parallel", current]) + current = [] + reserved_paths = [] + + def _add_sequential(tc) -> None: + _close_parallel() + if segments and segments[-1][0] == "sequential": + segments[-1][1].append(tc) + else: + segments.append(["sequential", [tc]]) + for tool_call in tool_calls: tool_name = tool_call.function.name + + if tool_name in _NEVER_PARALLEL_TOOLS: + _add_sequential(tool_call) + continue + try: function_args = json.loads(tool_call.function.arguments) except Exception: + _raw = tool_call.function.arguments logging.debug( - "Could not parse args for %s — defaulting to sequential; raw=%s", + "Could not parse args for %s — treating as sequential barrier; raw=%s", tool_name, - tool_call.function.arguments[:200], + _raw[:200] if isinstance(_raw, str) else repr(_raw)[:200], ) - return False + _add_sequential(tool_call) + continue if not isinstance(function_args, dict): logging.debug( - "Non-dict args for %s (%s) — defaulting to sequential", + "Non-dict args for %s (%s) — treating as sequential barrier", tool_name, type(function_args).__name__, ) - return False + _add_sequential(tool_call) + continue if tool_name in _PATH_SCOPED_TOOLS: scoped_path = _extract_parallel_scope_path(tool_name, function_args) if scoped_path is None: - return False + _add_sequential(tool_call) + continue if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths): - return False + # Same-subtree conflict inside this run: close it so this + # call starts a fresh run AFTER the conflicting one lands. + _close_parallel() reserved_paths.append(scoped_path) + current.append(tool_call) continue - if tool_name not in _PARALLEL_SAFE_TOOLS: - # Check if it's an MCP tool from a server that opted into parallel calls. - if not _is_mcp_tool_parallel_safe(tool_name): - return False + if tool_name in _PARALLEL_SAFE_TOOLS or _is_mcp_tool_parallel_safe(tool_name): + current.append(tool_call) + continue - return True + _add_sequential(tool_call) + + _close_parallel() + + normalized: list[list] = [] + for kind, calls in segments: + if kind == "parallel" and len(calls) < 2: + kind = "sequential" + if normalized and normalized[-1][0] == "sequential" and kind == "sequential": + normalized[-1][1].extend(calls) + else: + normalized.append([kind, calls]) + return [(kind, calls) for kind, calls in normalized] + + +def _should_parallelize_tool_batch(tool_calls) -> bool: + """Return True when the WHOLE tool-call batch is safe to run concurrently. + + Thin view over ``_plan_tool_batch_segments`` kept for callers/tests that + only care about the homogeneous case: True iff the planner produces a + single all-parallel segment. + """ + if len(tool_calls) <= 1: + return False + segments = _plan_tool_batch_segments(tool_calls) + return len(segments) == 1 and segments[0][0] == "parallel" def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]: @@ -542,6 +611,7 @@ __all__ = [ "_DESTRUCTIVE_PATTERNS", "_REDIRECT_OVERWRITE", "_is_destructive_command", + "_plan_tool_batch_segments", "_should_parallelize_tool_batch", "_extract_parallel_scope_path", "_paths_overlap", diff --git a/agent/tool_executor.py b/agent/tool_executor.py index ac505c6d8299..a5871442b449 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -36,6 +36,7 @@ from agent.tool_dispatch_helpers import ( _is_multimodal_tool_result, _multimodal_text_summary, _append_subdir_hint_to_multimodal, + _plan_tool_batch_segments, make_tool_result_message, ) from tools.terminal_tool import ( @@ -322,11 +323,15 @@ def _run_agent_tool_execution_middleware( return result, observed_args -def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: +def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None: """Execute multiple tool calls concurrently using a thread pool. Results are collected in the original tool-call order and appended to messages so the API sees them in the expected sequence. + + ``finalize=False`` skips the end-of-batch aggregate budget enforcement + and /steer injection — used when this call is one segment of a larger + mixed batch and the segmented dispatcher owns the turn-end work. """ tool_calls = assistant_message.tool_calls num_tools = len(tool_calls) @@ -1006,7 +1011,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # ── Per-turn aggregate budget enforcement ───────────────────────── num_tools = len(parsed_calls) - if num_tools > 0: + if finalize and num_tools > 0: turn_tool_msgs = messages[-num_tools:] enforce_turn_budget(turn_tool_msgs, env=get_active_env(effective_task_id), config=_tool_budget) @@ -1014,13 +1019,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # Append any pending user steer text to the last tool result so the # agent sees it on its next iteration. Runs AFTER budget enforcement # so the steer marker is never truncated. See steer() for details. - if num_tools > 0: + if finalize and num_tools > 0: agent._apply_pending_steer_to_tool_results(messages, num_tools) -def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: - """Execute tool calls sequentially (original behavior). Used for single calls or interactive tools.""" +def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None: + """Execute tool calls sequentially (original behavior). Used for single calls or interactive tools. + + ``finalize=False`` skips the end-of-batch aggregate budget enforcement + and /steer injection — used when this call is one segment of a larger + mixed batch and the segmented dispatcher owns the turn-end work. + """ # Resolve the context-scaled tool-output budget once per turn. _tool_budget = _budget_for_agent(agent) for i, tool_call in enumerate(assistant_message.tool_calls, 1): @@ -1716,19 +1726,73 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # ── Per-turn aggregate budget enforcement ───────────────────────── num_tools_seq = len(assistant_message.tool_calls) - if num_tools_seq > 0: + if finalize and num_tools_seq > 0: enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id), config=_tool_budget) # ── /steer injection ────────────────────────────────────────────── # See _execute_tool_calls_parallel for the rationale. Same hook, # applied to sequential execution as well. - if num_tools_seq > 0: + if finalize and num_tools_seq > 0: agent._apply_pending_steer_to_tool_results(messages, num_tools_seq) +def execute_tool_calls_segmented(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, segments=None) -> None: + """Execute a mixed tool-call batch as ordered parallel/sequential segments. + + ``segments`` is the ``(kind, calls)`` plan from + ``_plan_tool_batch_segments``: maximal contiguous runs of parallel-safe + calls execute on the concurrent path, barrier calls on the sequential + path, strictly in the model's original call order. Because segments are + contiguous, every tool result is still appended one-per-call in emission + order and no call ever starts before an earlier barrier finishes — + identical ordering and side-effect boundaries to fully-sequential + execution, with I/O parallelism recovered inside the safe runs. + + Turn-end work (aggregate budget enforcement + /steer injection) is done + once here for the WHOLE batch; the per-segment executor calls run with + ``finalize=False`` so a multi-segment turn cannot multiply the budget or + truncate a steer marker. + + Interrupt semantics: each segment executor already checks + ``agent._interrupt_requested`` up front and appends a cancelled/skipped + result per call, so an interrupt during segment *k* drains segments + *k+1..n* without executing them while preserving one result per + tool_call_id. + """ + from types import SimpleNamespace + + if segments is None: + segments = _plan_tool_batch_segments(assistant_message.tool_calls) + + for kind, calls in segments: + segment_message = SimpleNamespace(tool_calls=list(calls)) + if kind == "parallel": + execute_tool_calls_concurrent( + agent, segment_message, messages, effective_task_id, api_call_count, + finalize=False, + ) + else: + execute_tool_calls_sequential( + agent, segment_message, messages, effective_task_id, api_call_count, + finalize=False, + ) + + # ── Whole-turn finalize (budget + /steer) ───────────────────────── + total_tools = len(assistant_message.tool_calls) + if total_tools > 0: + _tool_budget = _budget_for_agent(agent) + enforce_turn_budget( + messages[-total_tools:], + env=get_active_env(effective_task_id), + config=_tool_budget, + ) + agent._apply_pending_steer_to_tool_results(messages, total_tools) + + __all__ = [ "execute_tool_calls_concurrent", "execute_tool_calls_sequential", + "execute_tool_calls_segmented", ] diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index 37f2d6179d11..7fc23cf506d9 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -44,6 +44,7 @@ Spawned by: CodexAppServerSession.ensure_started() when the runtime is from __future__ import annotations +import inspect import json import logging import os @@ -52,6 +53,49 @@ from typing import Any, Optional logger = logging.getLogger(__name__) +# JSON Schema type -> Python type mapping for signature generation +_JSON_TO_PY = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + +def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict[str, type]]: + """Build a Python function signature and annotations from a JSON schema. + + Args: + schema: JSON Schema dict with "properties" and "required" keys. + + Returns: + (signature, annotations_dict) where signature has KEYWORD_ONLY params + and annotations maps param names to Python types. + """ + props = (schema or {}).get("properties") or {} + required = set((schema or {}).get("required") or []) + params, annots = [], {} + + for pname, pspec in props.items(): + if pname.startswith("_"): + continue + py = _JSON_TO_PY.get((pspec or {}).get("type"), Any) + ann, default = ( + (py, inspect.Parameter.empty) + if pname in required + else (Optional[py], None) + ) + annots[pname] = ann + params.append( + inspect.Parameter( + pname, inspect.Parameter.KEYWORD_ONLY, annotation=ann, default=default + ) + ) + + return inspect.Signature(params, return_annotation=str), annots + # Tools we expose. Each name MUST match a registered Hermes tool that # `model_tools.handle_function_call()` can dispatch. @@ -159,29 +203,36 @@ def _build_server() -> Any: # the result string. We use add_tool() for full control over the # input schema (FastMCP's @tool() decorator inspects type hints, # which we can't get from a JSON schema at runtime). - def _make_handler(tool_name: str): + def _make_handler(tool_name: str, schema: dict | None): + sig, annots = _signature_from_schema(schema) + def _dispatch(**kwargs: Any) -> str: try: - return handle_function_call(tool_name, kwargs or {}) + # Filter out None values before dispatch so unset optionals + # aren't forwarded to the handler. + args = {k: v for k, v in kwargs.items() if v is not None} + return handle_function_call(tool_name, args or {}) except Exception as exc: logger.exception("tool %s raised", tool_name) return json.dumps({"error": str(exc), "tool": tool_name}) + _dispatch.__name__ = tool_name _dispatch.__doc__ = description + _dispatch.__signature__ = sig + _dispatch.__annotations__ = {**annots, "return": str} return _dispatch try: mcp.add_tool( - _make_handler(name), + _make_handler(name, params_schema), name=name, description=description, - # FastMCP accepts JSON schema directly via the - # input_schema parameter on newer versions; older - # versions use parameters_schema. Try both for compat. ) except TypeError: - # Older mcp SDK signature — fall back to decorator-style. - handler = _make_handler(name) + # Older mcp SDK signature — fall back to decorator-style. The + # synthesized __signature__ on the handler still drives schema + # generation there. + handler = _make_handler(name, params_schema) handler = mcp.tool(name=name, description=description)(handler) exposed_count += 1 diff --git a/agent/turn_context.py b/agent/turn_context.py index a5e738588e48..ea150ff30a7c 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -118,12 +118,12 @@ class TurnContext: def build_turn_context( agent, - user_message: str, + user_message: Any, system_message: Optional[str], conversation_history: Optional[List[Dict[str, Any]]], task_id: Optional[str], stream_callback, - persist_user_message: Optional[str], + persist_user_message: Optional[Any], persist_user_timestamp: Optional[float] = None, *, restore_or_build_system_prompt, @@ -271,6 +271,29 @@ def build_turn_context( # Initialize conversation (copy to avoid mutating the caller's list). messages = list(conversation_history) if conversation_history else [] + # The CLI may already have staged this input outside the history passed to + # ``run_conversation``. Reuse it only when its clean transcript text matches + # this turn; a stale handoff from a failed prior turn must not replace a + # later, different user input. Voice turns compare against their explicit + # clean persistence override rather than the API-only prefixed payload. + pending_cli_message = getattr(agent, "_pending_cli_user_message", None) + expected_persist_content = ( + persist_user_message if persist_user_message is not None else user_message + ) + if ( + isinstance(pending_cli_message, dict) + and pending_cli_message.get("content") == expected_persist_content + ): + user_msg = pending_cli_message + # The CLI-staged value is the clean transcript text. Restore the + # API-facing variant (for example, a voice-mode prefix) while retaining + # the same dict and any close-path durable marker. + user_msg["content"] = user_message + else: + user_msg = {"role": "user", "content": user_message} + if isinstance(pending_cli_message, dict): + agent._pending_cli_user_message = None + # Hydrate todo store from conversation history. if conversation_history and not agent._todo_store.has_items(): agent._hydrate_todo_store(conversation_history) @@ -285,6 +308,13 @@ def build_turn_context( if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0: agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval + # Add the current user message after the prompt/session setup has made + # close persistence safe. The handoff above preserves any marker already + # stamped by an earlier close flush. + messages.append(user_msg) + current_turn_user_idx = len(messages) - 1 + agent._persist_user_message_idx = current_turn_user_idx + # Track user turns for memory flush and periodic nudge logic. agent._user_turn_count += 1 # Copilot x-initiator: the first API call of this user turn is @@ -313,12 +343,6 @@ def build_turn_context( should_review_memory = True agent._turns_since_memory = 0 - # Add user message. - user_msg = {"role": "user", "content": user_message} - messages.append(user_msg) - current_turn_user_idx = len(messages) - 1 - agent._persist_user_message_idx = current_turn_user_idx - # Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot) # and notify the host so it can play hearts. Token-free, never touches the # conversation, and never fatal — a purely optional UI beat. @@ -348,18 +372,33 @@ def build_turn_context( # Create the DB session row now that _cached_system_prompt is populated, so # the persisted snapshot is written non-NULL on the first turn (Issue - # #45499). Idempotent: _ensure_db_session() no-ops once the row exists. - agent._ensure_db_session() + # #45499). Keep row creation and the marker-based append in the same + # per-agent critical section as CLI close persistence. + persist_lock = getattr(agent, "_session_persist_lock", None) + + def _ensure_and_persist() -> None: + agent._ensure_db_session() + agent._persist_session(messages, conversation_history) # Crash-resilience: persist the inbound user turn as soon as the session row exists. try: - agent._persist_session(messages, conversation_history) + if persist_lock is None: + _ensure_and_persist() + else: + with persist_lock: + _ensure_and_persist() except Exception: logger.warning( "Early turn-start session persistence failed for session=%s", agent.session_id or "none", exc_info=True, ) + finally: + # Keep an unmarked staged input available to a later close retry if the + # normal persistence attempt failed. Once the marker is present, the + # close path must no longer treat it as a pre-worker UI input. + if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"): + agent._pending_cli_user_message = None # ── Preflight context compression ── # Gate the (expensive) full token estimate behind a cheap pre-check. diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 1adc3c6c3632..fdf5babe1aea 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -228,6 +228,15 @@ def finalize_turn( if _tail_role != "assistant": messages.append({"role": "assistant", "content": final_response}) + # The model has completed its request, so replace API-local + # voice/model/skill guidance with the clean user input before writing the + # final durable snapshot and returning the continuation history. Earlier + # turn-start flushes use the DB-only override because their messages are + # still needed for the API request; this finalizer runs after that request + # is complete (#48677 / #63766). + _apply_override = getattr(agent, "_apply_persist_user_message_override", None) + if callable(_apply_override): + _apply_override(messages) agent._persist_session(messages, conversation_history) except Exception as _persist_err: _cleanup_errors.append(f"persist_session: {_persist_err}") diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0a4fd7f4e9cc..556a05b6d897 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -50,8 +50,8 @@ "check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build" }, "dependencies": { - "@assistant-ui/react": "^0.12.28", - "@assistant-ui/react-streamdown": "^0.1.11", + "@assistant-ui/react": "^0.14.23", + "@assistant-ui/react-streamdown": "^0.3.4", "@audiowave/react": "^0.6.2", "@chenglou/pretext": "^0.0.6", "@codemirror/commands": "^6.10.4", diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx new file mode 100644 index 000000000000..a403bde23800 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $compactingSessions, setSessionCompacting } from '@/store/compaction' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +const SID = 'session-1' +const OTHER_SID = 'session-2' +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { + act(() => handleEvent!({ payload, session_id: SID, type })) +} + +describe('useMessageStream compaction lifecycle', () => { + beforeEach(() => { + handleEvent = null + $compactingSessions.set({}) + }) + + afterEach(() => { + cleanup() + $compactingSessions.set({}) + vi.restoreAllMocks() + }) + + it.each([ + ['message.delta', { text: 'resumed' }], + ['thinking.delta', { text: 'still working' }], + ['reasoning.delta', { text: 'thinking again' }], + ['tool.start', { name: 'terminal', tool_id: 'tool-1' }] + ] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => { + await mountStream() + setSessionCompacting(OTHER_SID, true) + + emit('status.update', { kind: 'compacting' }) + expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true, [SID]: true }) + + emit(type, payload) + + expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index d33b345b55e9..58cabd78e9a4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -51,6 +51,19 @@ import type { ClientSessionState } from '../../../types' import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils' +const COMPACTION_RESUME_EVENT_TYPES = new Set([ + 'message.delta', + 'thinking.delta', + 'reasoning.delta', + 'reasoning.available', + 'moa.reference', + 'moa.aggregating', + 'tool.start', + 'tool.progress', + 'tool.generating', + 'tool.complete' +]) + interface GatewayEventDeps { activeSessionIdRef: MutableRefObject compactedTurnRef: MutableRefObject> @@ -119,6 +132,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const sessionId = route.sessionId const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current + // Mid-turn compaction does not emit another message.start. The first + // model output or tool event proves summarization has finished and the + // turn has resumed, so retire the phase label without waiting for the + // whole turn to complete. + if ( + sessionId && + COMPACTION_RESUME_EVENT_TYPES.has(event.type) && + compactedTurnRef.current.has(sessionId) + ) { + setSessionCompacting(sessionId, false) + } + if (event.type === 'gateway.ready') { return } else if (event.type === 'session.info') { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index b898e43e1caa..e35589010bd1 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { textPart } from '@/lib/chat-messages' import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer' -import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session' +import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment, usePromptActions } from '.' @@ -1075,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => { afterEach(() => { cleanup() + $turnStartedAt.set(null) vi.restoreAllMocks() }) @@ -1168,6 +1169,32 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) }) + it('clears the active and cached turn clocks when stopping a turn', async () => { + const states: Record[] = [] + const requestGateway = vi.fn(async () => ({}) as never) + $turnStartedAt.set(1_700_000_000_000) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={state => states.push(state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.cancelRun() + + expect($turnStartedAt.get()).toBeNull() + expect(states.at(-1)).toMatchObject({ + awaitingResponse: false, + busy: false, + interrupted: true, + turnStartedAt: null + }) + }) + it('surfaces the original error (no resume) when the failure is not "session not found"', async () => { const calls: string[] = [] const states: Record[] = [] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index fb7f053b49c8..10cf18eee0d7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -21,7 +21,15 @@ import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { clearPreviewArtifacts } from '@/store/preview-status' import { clearAllPrompts } from '@/store/prompts' -import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { + $busy, + $connection, + $messages, + setAwaitingResponse, + setBusy, + setMessages, + setTurnStartedAt +} from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' @@ -507,6 +515,7 @@ export function usePromptActions({ } setAwaitingResponse(false) + setTurnStartedAt(null) if (!sessionId) { releaseBusy() @@ -527,7 +536,8 @@ export function usePromptActions({ streamId: null, pendingBranchGroup: null, needsInput: false, - interrupted: true + interrupted: true, + turnStartedAt: null } }) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 559978e41568..32deb34d2d47 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -12,6 +12,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' +import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' @@ -248,6 +249,7 @@ export function AppearanceSettings() { const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) const translucency = useStore($translucency) + const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) @@ -451,6 +453,24 @@ export function AppearanceSettings() { title={a.translucencyTitle} /> + { + triggerHaptic('selection') + setBackdrop(id === 'on') + }} + options={[ + { id: 'off', label: t.common.off }, + { id: 'on', label: t.common.on } + ]} + value={backdrop ? 'on' : 'off'} + /> + } + description={a.backdropDesc} + title={a.backdropTitle} + /> + `${import.meta.env.BASE_URL}${path.replace(/ export function Backdrop() { const [controlsOpen, setControlsOpen] = useState(false) + const on = useStore($backdrop) useEffect(() => { if (!import.meta.env.DEV) { @@ -87,7 +91,7 @@ export function Backdrop() { <>