merge master into provider-aware model selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
17834a6e07
11 changed files with 1272 additions and 25 deletions
163
api/agent_runtime.py
Normal file
163
api/agent_runtime.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Fail-closed guard for in-process Hermes Agent source revisions.
|
||||
|
||||
Hermes WebUI currently imports ``run_agent.AIAgent`` into its long-lived server
|
||||
process. If the Agent checkout changes while that process is alive, Python may
|
||||
combine already-cached modules with newly-read source. Refuse to reuse that
|
||||
mixed runtime and require a clean WebUI restart instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
# Retain the discovered path as a diagnostic/test-visible compatibility value;
|
||||
# runtime identity is deliberately captured from the loaded module below.
|
||||
from api.config import _AGENT_DIR # noqa: F401
|
||||
|
||||
_RESTART_MESSAGE = (
|
||||
"Hermes Agent was updated while Hermes WebUI was running. "
|
||||
"Restart Hermes WebUI before retrying this action."
|
||||
)
|
||||
|
||||
|
||||
def _read_agent_revision(
|
||||
agent_dir: Path | None,
|
||||
*,
|
||||
module_path: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Return the loaded Agent checkout HEAD, or ``None`` if it is not tracked."""
|
||||
if agent_dir is None:
|
||||
return None
|
||||
|
||||
if module_path is None:
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
try:
|
||||
worktree_result = subprocess.run(
|
||||
["git", "-C", str(agent_dir), "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if worktree_result.returncode != 0:
|
||||
return None
|
||||
worktree = Path(worktree_result.stdout.strip()).resolve()
|
||||
relative_module = module_path.relative_to(worktree).as_posix()
|
||||
tracked_result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--literal-pathspecs",
|
||||
"-C",
|
||||
str(worktree),
|
||||
"ls-files",
|
||||
"--error-unmatch",
|
||||
"--",
|
||||
relative_module,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if tracked_result.returncode != 0:
|
||||
return None
|
||||
revision_result = subprocess.run(
|
||||
["git", "-C", str(worktree), "rev-parse", "--verify", "HEAD"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
revision = revision_result.stdout.strip()
|
||||
return revision if revision_result.returncode == 0 and revision else None
|
||||
|
||||
|
||||
_AGENT_SOURCE_DIR: Path | None = None
|
||||
_AGENT_MODULE_PATH: Path | None = None
|
||||
_AGENT_REVISION: str | None = None
|
||||
_AIAgent = None
|
||||
_RUNTIME_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class AgentRuntimeChangedError(RuntimeError):
|
||||
"""Raised when the loaded Agent runtime no longer matches its source tree."""
|
||||
|
||||
|
||||
def _loaded_agent_source_identity() -> tuple[Path, Path] | None:
|
||||
"""Return the source directory and file that supplied ``run_agent``."""
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
return module_path.parent, module_path
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _capture_loaded_agent_revision() -> None:
|
||||
"""Bind the guard to the checkout that supplied the loaded Agent module."""
|
||||
global _AGENT_SOURCE_DIR, _AGENT_MODULE_PATH, _AGENT_REVISION
|
||||
|
||||
if _AGENT_REVISION is not None:
|
||||
ensure_agent_runtime_current()
|
||||
return
|
||||
|
||||
identity = _loaded_agent_source_identity()
|
||||
if identity is None:
|
||||
return
|
||||
source_dir, module_path = identity
|
||||
current_revision = _read_agent_revision(source_dir, module_path=module_path)
|
||||
_AGENT_SOURCE_DIR = source_dir
|
||||
_AGENT_MODULE_PATH = module_path
|
||||
_AGENT_REVISION = current_revision
|
||||
|
||||
|
||||
def ensure_agent_runtime_current() -> None:
|
||||
"""Reject a known Git checkout change instead of mixing Python modules."""
|
||||
if _AGENT_REVISION is None:
|
||||
return
|
||||
if (
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
|
||||
|
||||
|
||||
def require_ai_agent_class():
|
||||
"""Import ``AIAgent`` after proving the loaded source revision is current."""
|
||||
ensure_agent_runtime_current()
|
||||
from run_agent import AIAgent # noqa: PLC0415
|
||||
|
||||
_capture_loaded_agent_revision()
|
||||
return AIAgent
|
||||
|
||||
|
||||
def get_ai_agent_class():
|
||||
"""Return ``AIAgent`` while preserving the existing lazy-import retry."""
|
||||
global _AIAgent, _AGENT_REVISION
|
||||
|
||||
with _RUNTIME_LOCK:
|
||||
ensure_agent_runtime_current()
|
||||
if _AIAgent is None:
|
||||
try:
|
||||
agent_class = require_ai_agent_class()
|
||||
except ImportError:
|
||||
return None
|
||||
_AIAgent = agent_class
|
||||
return _AIAgent
|
||||
153
api/routes.py
153
api/routes.py
|
|
@ -34,6 +34,11 @@ from contextlib import closing
|
|||
from urllib.parse import parse_qs, quote, unquote, urljoin, urlsplit
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
||||
from api.agent_runtime import (
|
||||
AgentRuntimeChangedError,
|
||||
ensure_agent_runtime_current,
|
||||
require_ai_agent_class,
|
||||
)
|
||||
from api.agent_sessions import (
|
||||
MESSAGING_SOURCES,
|
||||
_looks_like_default_cli_title,
|
||||
|
|
@ -16052,6 +16057,7 @@ def handle_post(handler, parsed) -> bool:
|
|||
"api_key": _main_api_key,
|
||||
}
|
||||
|
||||
ensure_agent_runtime_current()
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
|
||||
|
|
@ -16072,7 +16078,7 @@ def handle_post(handler, parsed) -> bool:
|
|||
except Exception as _e:
|
||||
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
|
||||
|
||||
from run_agent import AIAgent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
agent = AIAgent(
|
||||
model=_main_model,
|
||||
|
|
@ -20425,6 +20431,9 @@ def _handle_btw(handler, body):
|
|||
require(body, "question")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
|
||||
return bad(handler, "Subagent sessions are view-only and cannot be used for /btw from WebUI", 400)
|
||||
try:
|
||||
|
|
@ -20484,6 +20493,9 @@ def _handle_background(handler, body):
|
|||
require(body, "prompt")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
try:
|
||||
s = get_session(body["session_id"])
|
||||
except KeyError:
|
||||
|
|
@ -20770,6 +20782,31 @@ def _active_run_stream_for_session(session_id: str | None) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _agent_runtime_barrier_response(
|
||||
*,
|
||||
runner_local_owned: bool = False,
|
||||
external_runtime_owned: bool | None = None,
|
||||
) -> dict | None:
|
||||
"""Return the typed stale-runtime response for local in-process turns."""
|
||||
if external_runtime_owned is True:
|
||||
return None
|
||||
if runner_local_owned and webui_gateway_chat_enabled(get_config()):
|
||||
return None
|
||||
from api.runtime_adapter import runtime_adapter_runner_enabled
|
||||
|
||||
if runner_local_owned and runtime_adapter_runner_enabled():
|
||||
return None
|
||||
try:
|
||||
ensure_agent_runtime_current()
|
||||
except AgentRuntimeChangedError as exc:
|
||||
return {
|
||||
"error": str(exc),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _start_chat_stream_for_session(
|
||||
s,
|
||||
*,
|
||||
|
|
@ -20783,8 +20820,18 @@ def _start_chat_stream_for_session(
|
|||
goal_related: bool = False,
|
||||
source: str = "webui",
|
||||
moa_config=None,
|
||||
external_runtime_owned: bool | None = None,
|
||||
):
|
||||
"""Persist pending state, register an SSE channel, and start an agent turn."""
|
||||
if external_runtime_owned is None:
|
||||
external_runtime_owned = webui_gateway_chat_enabled(get_config())
|
||||
backend_is_gateway = bool(external_runtime_owned)
|
||||
stale_response = _agent_runtime_barrier_response(
|
||||
external_runtime_owned=backend_is_gateway,
|
||||
)
|
||||
if stale_response is not None:
|
||||
stale_response["_status"] = 409
|
||||
return stale_response
|
||||
attachments = attachments or []
|
||||
# Prevent duplicate runs in the same session while a stream is still active.
|
||||
# This commonly happens after page refresh/reconnect races and can produce
|
||||
|
|
@ -20903,7 +20950,6 @@ def _start_chat_stream_for_session(
|
|||
if goal_related:
|
||||
STREAM_GOAL_RELATED[stream_id] = True
|
||||
diag.stage("worker_thread_start") if diag else None
|
||||
backend_is_gateway = webui_gateway_chat_enabled(get_config())
|
||||
worker_target = _run_gateway_chat_streaming if backend_is_gateway else _run_agent_streaming
|
||||
worker_kwargs = {"model_provider": model_provider, "goal_related": goal_related}
|
||||
if moa_config and not backend_is_gateway:
|
||||
|
|
@ -21074,6 +21120,7 @@ def _start_run(
|
|||
diag=diag,
|
||||
source=source,
|
||||
moa_config=moa_config,
|
||||
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -21167,6 +21214,10 @@ def start_session_turn(
|
|||
msg = str(message or "").strip()
|
||||
if not msg:
|
||||
return {"error": "message is required", "_status": 400}
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=True)
|
||||
if stale_response is not None:
|
||||
stale_response["_status"] = 409
|
||||
return stale_response
|
||||
turn_source = str(source or "process_wakeup").strip() or "process_wakeup"
|
||||
try:
|
||||
s = get_session(session_id)
|
||||
|
|
@ -21643,6 +21694,7 @@ def _handle_goal_command(handler, body):
|
|||
model_provider=model_provider,
|
||||
normalized_model=normalized_model,
|
||||
goal_related=True,
|
||||
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
|
||||
)
|
||||
status = int(stream_response.pop("_status", 200) or 200)
|
||||
payload.update(stream_response)
|
||||
|
|
@ -21661,6 +21713,12 @@ def _handle_chat_start(handler, body, diag=None):
|
|||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
# Reject a stale local Agent runtime before materialising, claiming, or
|
||||
# mutating any session state. Gateway-backed turns run in the gateway's
|
||||
# process and do not depend on this WebUI process's imported checkout.
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=True)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
diag.stage("get_session") if diag else None
|
||||
try:
|
||||
s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True)
|
||||
|
|
@ -21956,6 +22014,9 @@ def _normalize_chat_attachments(raw_attachments):
|
|||
|
||||
def _handle_chat_sync(handler, body):
|
||||
"""Fallback synchronous chat endpoint (POST /api/chat). Not used by frontend."""
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
|
||||
return bad(handler, "Subagent sessions are view-only and cannot be written from WebUI", 400)
|
||||
s = get_session(body["session_id"])
|
||||
|
|
@ -21991,7 +22052,7 @@ def _handle_chat_sync(handler, body):
|
|||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = s.session_id
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
with CHAT_LOCK:
|
||||
from api.config import (
|
||||
|
|
@ -22622,6 +22683,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
|
|||
"base_url": _main_base_url,
|
||||
"api_key": _main_api_key,
|
||||
}
|
||||
ensure_agent_runtime_current()
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
|
||||
|
|
@ -22638,7 +22700,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
|
|||
except Exception as _e:
|
||||
logger.debug("git commit message auxiliary model failed; falling back to main model: %s", _e)
|
||||
|
||||
from run_agent import AIAgent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
agent = AIAgent(
|
||||
model=_main_model,
|
||||
|
|
@ -22684,6 +22746,12 @@ def _handle_git_commit_message(handler, body):
|
|||
return bad(handler, str(e))
|
||||
except GitWorkspaceError as e:
|
||||
return _git_bad(handler, e)
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.exception("git commit message generation failed")
|
||||
return bad(handler, _sanitize_error(e), 500)
|
||||
|
|
@ -22715,6 +22783,12 @@ def _handle_git_commit_message_selected(handler, body):
|
|||
return bad(handler, str(e))
|
||||
except GitWorkspaceError as e:
|
||||
return _git_bad(handler, e)
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.exception("selected git commit message generation failed")
|
||||
return bad(handler, _sanitize_error(e), 500)
|
||||
|
|
@ -23739,6 +23813,10 @@ def _manual_compression_status_payload(job):
|
|||
payload["ok"] = False
|
||||
payload["error"] = job.get("error") or "Compression failed"
|
||||
payload["error_status"] = int(job.get("error_status") or 400)
|
||||
if job.get("error_type"):
|
||||
payload["type"] = job["error_type"]
|
||||
if job.get("retryable") is not None:
|
||||
payload["retryable"] = bool(job["retryable"])
|
||||
elif status == "cancelled":
|
||||
payload["ok"] = False
|
||||
payload["error"] = job.get("error") or "Compression cancelled"
|
||||
|
|
@ -23773,6 +23851,8 @@ def _run_manual_compression_job(sid, body):
|
|||
"status": "error",
|
||||
"error": str((payload or {}).get("error") or "Compression failed"),
|
||||
"error_status": status,
|
||||
"error_type": (payload or {}).get("type"),
|
||||
"retryable": (payload or {}).get("retryable"),
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
|
@ -23784,6 +23864,21 @@ def _run_manual_compression_job(sid, body):
|
|||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
except AgentRuntimeChangedError as exc:
|
||||
logger.warning("Manual compression worker found stale Agent runtime for session %s", sid)
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
job = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if job:
|
||||
job.update(
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
"error_status": 409,
|
||||
"error_type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual compression worker failed for session %s: %s", sid, exc)
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
|
|
@ -23820,6 +23915,34 @@ def _handle_session_compress_start(handler, body):
|
|||
if focus_topic:
|
||||
job_body["focus_topic"] = focus_topic
|
||||
|
||||
# Repeated start requests observe an existing running job and do not admit
|
||||
# new Agent work, so preserve that idempotent read even if the checkout has
|
||||
# since changed. Do not hold the job lock while running Git subprocesses.
|
||||
now = time.time()
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
_manual_compression_cleanup_locked(now)
|
||||
existing = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if existing:
|
||||
existing_payload = _manual_compression_status_payload(existing)
|
||||
if existing_payload.get("status") == "running":
|
||||
return j(handler, existing_payload)
|
||||
|
||||
# Reject a stale local Agent runtime before creating the asynchronous job.
|
||||
try:
|
||||
ensure_agent_runtime_current()
|
||||
except AgentRuntimeChangedError as exc:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"error": str(exc),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
# Another start request may have admitted a job while the runtime check ran.
|
||||
# Re-check under the lock so only one worker is created.
|
||||
now = time.time()
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
_manual_compression_cleanup_locked(now)
|
||||
|
|
@ -24035,10 +24158,11 @@ def _handle_session_compress(handler, body):
|
|||
focus_topic,
|
||||
)
|
||||
|
||||
ensure_agent_runtime_current()
|
||||
import api.config as _cfg
|
||||
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
|
||||
import hermes_cli.runtime_provider as _runtime_provider
|
||||
import run_agent as _run_agent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(
|
||||
_cfg.model_with_provider_context(s.model, getattr(s, "model_provider", None))
|
||||
|
|
@ -24081,7 +24205,7 @@ def _handle_session_compress(handler, body):
|
|||
)
|
||||
approx_tokens = _estimate_messages_tokens_rough(original_messages)
|
||||
|
||||
agent = _run_agent.AIAgent(
|
||||
agent = AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
|
|
@ -24176,6 +24300,12 @@ def _handle_session_compress(handler, body):
|
|||
"focus_topic": focus_topic,
|
||||
},
|
||||
)
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.warning("Manual session compression failed: %s", e)
|
||||
return bad(handler, f"Compression failed: {_sanitize_error(e)}")
|
||||
|
|
@ -24685,10 +24815,11 @@ def _handle_handoff_summary(handler, body):
|
|||
|
||||
# Call LLM for summary.
|
||||
try:
|
||||
ensure_agent_runtime_current()
|
||||
import api.config as _cfg
|
||||
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
|
||||
import hermes_cli.runtime_provider as _runtime_provider
|
||||
import run_agent as _run_agent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
# Try to resolve model from an existing session, fall back to default.
|
||||
resolved_model = None
|
||||
|
|
@ -24744,7 +24875,7 @@ def _handle_handoff_summary(handler, body):
|
|||
"fallback": True,
|
||||
})
|
||||
|
||||
agent = _run_agent.AIAgent(
|
||||
agent = AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
|
|
@ -24824,6 +24955,12 @@ def _handle_handoff_summary(handler, body):
|
|||
"rounds": rounds,
|
||||
"fallback": fallback,
|
||||
})
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.warning("Handoff summary generation failed: %s", e)
|
||||
summary_text = _fallback_handoff_summary(msgs)
|
||||
|
|
|
|||
|
|
@ -564,10 +564,13 @@ def _prewarm_skill_tool_modules():
|
|||
|
||||
|
||||
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
except ImportError:
|
||||
AIAgent = None
|
||||
from api.agent_runtime import ensure_agent_runtime_current, get_ai_agent_class
|
||||
|
||||
|
||||
# Eagerly attempt the import at startup, matching the pre-guard behavior. If
|
||||
# dependencies are not ready yet, _get_ai_agent() retries when a chat starts.
|
||||
AIAgent = get_ai_agent_class()
|
||||
|
||||
|
||||
def _get_ai_agent():
|
||||
"""Return AIAgent class, retrying the import if the initial attempt failed.
|
||||
|
|
@ -575,15 +578,13 @@ def _get_ai_agent():
|
|||
auto_install_agent_deps() in server.py may install missing packages after
|
||||
this module is first imported (common in Docker with a volume-mounted agent).
|
||||
Re-attempting the import here picks up the newly installed packages without
|
||||
requiring a server restart.
|
||||
requiring a server restart. The shared runtime guard also refuses to reuse
|
||||
cached Agent modules after the source checkout changes.
|
||||
"""
|
||||
global AIAgent
|
||||
ensure_agent_runtime_current()
|
||||
if AIAgent is None:
|
||||
try:
|
||||
from run_agent import AIAgent as _cls # noqa: PLC0415
|
||||
AIAgent = _cls
|
||||
except ImportError:
|
||||
pass
|
||||
AIAgent = get_ai_agent_class()
|
||||
return AIAgent
|
||||
|
||||
|
||||
|
|
@ -7296,6 +7297,7 @@ def _run_agent_streaming(
|
|||
logger.debug("register_process_session failed", exc_info=True)
|
||||
# first-time module initialisation (which can be slow) does not
|
||||
# block other concurrent sessions waiting on _ENV_LOCK (#2024).
|
||||
ensure_agent_runtime_current()
|
||||
_prewarm_skill_tool_modules()
|
||||
_install_streaming_cronjob_profile_wrapper()
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
|
|
|
|||
|
|
@ -177,6 +177,28 @@ turn in the exhausted session instead of being blocked with recovery guidance.
|
|||
|
||||
---
|
||||
|
||||
## "Hermes Agent was updated while Hermes WebUI was running"
|
||||
|
||||
**Symptom.** An action that uses the in-process Agent runtime stops with a message telling you to restart Hermes WebUI. This can happen after `hermes update`, a Git checkout/pull in the Agent source tree, or another tool updates Hermes Agent without restarting the already-running WebUI backend.
|
||||
|
||||
**Why.** WebUI currently imports `run_agent.AIAgent` into its long-lived Python process. Python keeps imported modules in memory. Continuing after a known Agent Git revision changes could combine cached modules from the old revision with source read from the new revision, producing misleading `ImportError`s or inconsistent runtime state. For local Agent-backed chat, WebUI therefore returns a retryable `409 agent_runtime_stale` before claiming or mutating session state instead of attempting a partial in-process reload. Gateway-backed chat runs in the gateway process and is not blocked by this WebUI-local check. Non-Git Agent installs preserve their existing behavior because there is no revision identity to compare.
|
||||
|
||||
**Diagnostic.** Compare the running WebUI process start time with the Agent checkout revision and recent update history. If the Agent was updated after WebUI started, restart WebUI before investigating individual missing-symbol errors.
|
||||
|
||||
**Fix.** Restart using the same launch method that started WebUI:
|
||||
|
||||
```bash
|
||||
./ctl.sh restart
|
||||
# Or, for a user systemd service:
|
||||
systemctl --user restart hermes-webui.service
|
||||
```
|
||||
|
||||
If you launched `python3 bootstrap.py` in the foreground, stop it with Ctrl-C and start it again. Restarting the whole computer or WSL is not required when restarting the WebUI backend succeeds.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if the restart-required message appears even though the Agent revision did not change, or if a clean WebUI restart still produces the same import error. Include the WebUI launch method, WebUI revision, Agent revision, and the sanitized error text.
|
||||
|
||||
---
|
||||
|
||||
## Other troubleshooting
|
||||
|
||||
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
|
||||
|
|
|
|||
687
tests/test_agent_runtime_revision_guard.py
Normal file
687
tests/test_agent_runtime_revision_guard.py
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
"""Regression coverage for Hermes Agent source changes during a WebUI process lifetime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_loaded_agent_runtime_fails_closed_after_source_revision_changes(tmp_path: Path):
|
||||
agent_dir = tmp_path / "hermes-agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "run_agent.py").write_text(
|
||||
"class AIAgent:\n revision = 'before'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(agent_dir, "init", "-q")
|
||||
_git(agent_dir, "add", "run_agent.py")
|
||||
_git(agent_dir, "commit", "-qm", "before")
|
||||
|
||||
probe = tmp_path / "probe.py"
|
||||
probe.write_text(
|
||||
"""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import api.streaming as streaming
|
||||
from api import agent_runtime
|
||||
|
||||
agent_dir = Path(__file__).parent / "hermes-agent"
|
||||
assert agent_runtime._AGENT_DIR == agent_dir.resolve()
|
||||
assert streaming._get_ai_agent().revision == "before"
|
||||
|
||||
(agent_dir / "run_agent.py").write_text(
|
||||
"class AIAgent:\\n revision = 'after'\\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run(["git", "add", "run_agent.py"], cwd=agent_dir, check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid",
|
||||
"commit", "-qm", "after",
|
||||
],
|
||||
cwd=agent_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
try:
|
||||
streaming._get_ai_agent()
|
||||
except RuntimeError as exc:
|
||||
message = str(exc)
|
||||
assert "Hermes Agent was updated" in message
|
||||
assert "Restart Hermes WebUI" in message
|
||||
else:
|
||||
raise AssertionError("stale in-process AIAgent was reused after its source revision changed")
|
||||
|
||||
try:
|
||||
agent_runtime.require_ai_agent_class()
|
||||
except agent_runtime.AgentRuntimeChangedError as exc:
|
||||
assert "Restart Hermes WebUI" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unguarded AIAgent import was allowed after its source revision changed")
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"HERMES_WEBUI_AGENT_DIR": str(agent_dir),
|
||||
"HERMES_HOME": str(tmp_path / "hermes-home"),
|
||||
"HERMES_WEBUI_STATE_DIR": str(tmp_path / "webui-state"),
|
||||
"PYTHONPATH": os.pathsep.join((str(REPO), str(agent_dir))),
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(probe)],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_initial_non_git_source_preserves_supported_runtime(monkeypatch):
|
||||
"""Non-Git installs cannot be compared, so they preserve existing behavior."""
|
||||
from api import agent_runtime
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
monkeypatch.setattr(
|
||||
agent_runtime,
|
||||
"_read_agent_revision",
|
||||
lambda _path, **_kwargs: None,
|
||||
)
|
||||
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
|
||||
|
||||
def test_untracked_loaded_module_inside_outer_git_repo_is_non_git(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
"""An enclosing unrelated Git repo must not identify an installed module."""
|
||||
from api import agent_runtime
|
||||
|
||||
outer_repo = tmp_path / "outer-repo"
|
||||
module_dir = outer_repo / "installed" / "hermes-agent"
|
||||
module_dir.mkdir(parents=True)
|
||||
module_file = module_dir / "run_agent.py"
|
||||
module_file.write_text("class AIAgent: pass\n", encoding="utf-8")
|
||||
(outer_repo / "tracked.txt").write_text("unrelated\n", encoding="utf-8")
|
||||
_git(outer_repo, "init", "-q")
|
||||
_git(outer_repo, "add", "tracked.txt")
|
||||
_git(outer_repo, "commit", "-qm", "unrelated")
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(module_file)
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == module_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == module_file.resolve()
|
||||
assert agent_runtime._AGENT_REVISION is None
|
||||
|
||||
(outer_repo / "tracked.txt").write_text("unrelated change\n", encoding="utf-8")
|
||||
_git(outer_repo, "add", "tracked.txt")
|
||||
_git(outer_repo, "commit", "-qm", "unrelated change")
|
||||
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
|
||||
|
||||
def test_untracked_module_pathspec_metacharacters_are_literal(monkeypatch, tmp_path: Path):
|
||||
"""Git pathspec syntax must not turn an untracked module into a tracked match."""
|
||||
from api import agent_runtime
|
||||
|
||||
outer_repo = tmp_path / "outer-repo"
|
||||
tracked_dir = outer_repo / "installed" / "agent"
|
||||
untracked_dir = outer_repo / "installed" / "[a]gent"
|
||||
tracked_dir.mkdir(parents=True)
|
||||
untracked_dir.mkdir(parents=True)
|
||||
(tracked_dir / "run_agent.py").write_text("class Other: pass\n", encoding="utf-8")
|
||||
module_file = untracked_dir / "run_agent.py"
|
||||
module_file.write_text("class AIAgent: pass\n", encoding="utf-8")
|
||||
_git(outer_repo, "init", "-q")
|
||||
_git(outer_repo, "add", "installed/agent/run_agent.py")
|
||||
_git(outer_repo, "commit", "-qm", "tracked lookalike")
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(module_file)
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == untracked_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == module_file.resolve()
|
||||
assert agent_runtime._AGENT_REVISION is None
|
||||
|
||||
|
||||
def test_revision_identity_comes_from_loaded_agent_module(monkeypatch, tmp_path: Path):
|
||||
"""The configured discovery path must not override the loaded module path."""
|
||||
from api import agent_runtime
|
||||
|
||||
configured_dir = tmp_path / "configured-agent"
|
||||
loaded_dir = tmp_path / "loaded-agent"
|
||||
for agent_dir, revision in ((configured_dir, "configured"), (loaded_dir, "loaded")):
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "run_agent.py").write_text(
|
||||
f"class AIAgent:\n revision = '{revision}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(agent_dir, "init", "-q")
|
||||
_git(agent_dir, "add", "run_agent.py")
|
||||
_git(agent_dir, "commit", "-qm", revision)
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(loaded_dir / "run_agent.py")
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_DIR", configured_dir)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == loaded_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == (loaded_dir / "run_agent.py").resolve()
|
||||
assert agent_runtime._AGENT_REVISION == _git(loaded_dir, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_known_revision_becoming_unreadable_fails_closed(monkeypatch):
|
||||
"""Losing a previously-known revision is indistinguishable from source drift."""
|
||||
from api import agent_runtime
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "known-revision")
|
||||
monkeypatch.setattr(
|
||||
agent_runtime,
|
||||
"_read_agent_revision",
|
||||
lambda _path, **_kwargs: None,
|
||||
)
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
|
||||
|
||||
def test_import_recapture_cannot_downgrade_known_revision(monkeypatch, tmp_path: Path):
|
||||
"""A second unreadable revision read must not erase a known identity."""
|
||||
from api import agent_runtime
|
||||
|
||||
source_dir = tmp_path / "loaded-agent"
|
||||
source_dir.mkdir()
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(source_dir / "run_agent.py")
|
||||
loaded_module.__dict__["AIAgent"] = type("AIAgent", (), {})
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", source_dir / "run_agent.py")
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "known-revision")
|
||||
revisions = iter(("known-revision", None))
|
||||
monkeypatch.setattr(
|
||||
agent_runtime,
|
||||
"_read_agent_revision",
|
||||
lambda _path, **_kwargs: next(revisions),
|
||||
)
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.require_ai_agent_class()
|
||||
|
||||
assert agent_runtime._AGENT_REVISION == "known-revision"
|
||||
|
||||
|
||||
def test_in_memory_agent_swap_does_not_mimic_source_revision_change(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
"""Only the bound checkout revision, not ``sys.modules`` swaps, defines staleness."""
|
||||
from api import agent_runtime
|
||||
|
||||
agent_dir = tmp_path / "loaded-agent"
|
||||
agent_dir.mkdir()
|
||||
module_file = agent_dir / "run_agent.py"
|
||||
module_file.write_text("class AIAgent: pass\n", encoding="utf-8")
|
||||
_git(agent_dir, "init", "-q")
|
||||
_git(agent_dir, "add", "run_agent.py")
|
||||
_git(agent_dir, "commit", "-qm", "loaded agent")
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(module_file)
|
||||
loaded_module.__dict__["AIAgent"] = type("LoadedAgent", (), {})
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
replacement_class = type("ReplacementAgent", (), {})
|
||||
replacement_module = types.ModuleType("run_agent")
|
||||
replacement_module.__dict__["AIAgent"] = replacement_class
|
||||
monkeypatch.setitem(sys.modules, "run_agent", replacement_module)
|
||||
|
||||
assert agent_runtime.require_ai_agent_class() is replacement_class
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == agent_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == module_file.resolve()
|
||||
assert agent_runtime._AGENT_REVISION == _git(agent_dir, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_runner_local_bypasses_webui_agent_barrier(monkeypatch):
|
||||
"""Runner-local execution is owned by the runner, not this process."""
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("runner-local must not inspect the WebUI Agent checkout")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.runtime_adapter.runtime_adapter_runner_enabled",
|
||||
lambda: True,
|
||||
)
|
||||
|
||||
assert routes._agent_runtime_barrier_response(runner_local_owned=True) is None
|
||||
|
||||
|
||||
def test_runner_flag_does_not_bypass_webui_owned_hidden_turns(monkeypatch):
|
||||
"""Runner/gateway modes do not transfer ownership of legacy hidden turns."""
|
||||
from api import agent_runtime, routes
|
||||
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"api.runtime_adapter.runtime_adapter_runner_enabled",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
agent_runtime.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
|
||||
response = routes._agent_runtime_barrier_response(runner_local_owned=False)
|
||||
|
||||
assert response == {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_start_rejects_stale_runtime_before_session_materialization(monkeypatch):
|
||||
"""A stale local runtime must not claim, create, or mutate session state."""
|
||||
from api import agent_runtime, routes
|
||||
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
|
||||
def stale():
|
||||
raise agent_runtime.AgentRuntimeChangedError("restart required")
|
||||
|
||||
monkeypatch.setattr(routes, "ensure_agent_runtime_current", stale)
|
||||
|
||||
def must_not_materialize(*_args, **_kwargs):
|
||||
raise AssertionError("session materialized before stale-runtime barrier")
|
||||
|
||||
monkeypatch.setattr(routes, "_get_or_materialize_session", must_not_materialize)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda _handler, payload, status=200: {"status": status, "payload": payload},
|
||||
)
|
||||
|
||||
response = routes._handle_chat_start(object(), {"session_id": "session-1"})
|
||||
|
||||
assert response == {
|
||||
"status": 409,
|
||||
"payload": {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_runner_owned_start_run_does_not_enter_local_stream_barrier(monkeypatch):
|
||||
"""The runner adapter must not invoke the WebUI in-process delegate."""
|
||||
from api import routes
|
||||
|
||||
calls = []
|
||||
|
||||
class RunnerClient:
|
||||
def start_run(self, request):
|
||||
calls.append(request)
|
||||
return {
|
||||
"run_id": "runner-run-1",
|
||||
"stream_id": "runner-stream-1",
|
||||
"session_id": request.session_id,
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
session = types.SimpleNamespace(session_id="session-1", profile=None)
|
||||
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "runner-local")
|
||||
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False)
|
||||
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_runner_enabled", lambda: True)
|
||||
monkeypatch.setattr(routes, "_runtime_runner_client_factory", lambda: RunnerClient())
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_start_chat_stream_for_session",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("runner-owned start entered the WebUI local stream path")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("runner-owned start inspected the WebUI Agent revision")
|
||||
),
|
||||
)
|
||||
|
||||
response = routes._start_run(
|
||||
session,
|
||||
msg="hello",
|
||||
attachments=[],
|
||||
workspace="/tmp/workspace",
|
||||
model="test-model",
|
||||
model_provider="test-provider",
|
||||
normalized_model=False,
|
||||
source="webui",
|
||||
route="/api/chat/start",
|
||||
)
|
||||
|
||||
assert response.get("stream_id") == "runner-stream-1", response
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gateway_owned", [False, True])
|
||||
def test_stream_admission_uses_one_gateway_ownership_snapshot(monkeypatch, gateway_owned):
|
||||
"""The barrier and worker must share one immutable backend decision."""
|
||||
from api import routes
|
||||
from api import turn_journal
|
||||
from api.config import unregister_stream_owner
|
||||
|
||||
gateway_reads = []
|
||||
revision_checks = []
|
||||
worker_targets = []
|
||||
session = types.SimpleNamespace(
|
||||
session_id="gateway-snapshot",
|
||||
profile=None,
|
||||
title="Gateway snapshot",
|
||||
active_stream_id=None,
|
||||
pending_started_at=None,
|
||||
)
|
||||
|
||||
def read_gateway(_cfg):
|
||||
gateway_reads.append(True)
|
||||
if len(gateway_reads) > 1:
|
||||
raise AssertionError("gateway ownership was re-read after admission")
|
||||
return gateway_owned
|
||||
|
||||
def prepare(current, **kwargs):
|
||||
current.active_stream_id = kwargs["stream_id"]
|
||||
current.pending_started_at = 123.0
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, *, target, args, kwargs, daemon):
|
||||
worker_targets.append(target)
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", read_gateway)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: revision_checks.append(True),
|
||||
)
|
||||
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda _sid: None)
|
||||
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False)
|
||||
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", prepare)
|
||||
monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None)
|
||||
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
|
||||
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {})
|
||||
|
||||
response = routes._start_chat_stream_for_session(
|
||||
session,
|
||||
msg="goal",
|
||||
attachments=[],
|
||||
workspace="/tmp/workspace",
|
||||
model="test-model",
|
||||
goal_related=True,
|
||||
)
|
||||
|
||||
try:
|
||||
assert response["session_id"] == session.session_id
|
||||
assert len(gateway_reads) == 1
|
||||
assert revision_checks == ([] if gateway_owned else [True])
|
||||
expected_worker = (
|
||||
routes._run_gateway_chat_streaming
|
||||
if gateway_owned
|
||||
else routes._run_agent_streaming
|
||||
)
|
||||
assert worker_targets == [expected_worker]
|
||||
finally:
|
||||
stream_id = str(response.get("stream_id") or "")
|
||||
with routes.STREAMS_LOCK:
|
||||
routes.STREAMS.pop(stream_id, None)
|
||||
unregister_stream_owner(stream_id)
|
||||
routes.STREAM_GOAL_RELATED.pop(stream_id, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "body"),
|
||||
[
|
||||
("_handle_git_commit_message", {"session_id": "git-message"}),
|
||||
(
|
||||
"_handle_git_commit_message_selected",
|
||||
{"session_id": "git-message", "paths": ["selected.py"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_git_commit_message_stale_runtime_returns_typed_409(
|
||||
monkeypatch, tmp_path, handler_name, body
|
||||
):
|
||||
"""Commit-message generation must preserve the stale-runtime contract."""
|
||||
from api import routes
|
||||
from api import workspace_git
|
||||
|
||||
session = types.SimpleNamespace(workspace=str(tmp_path))
|
||||
monkeypatch.setattr(routes, "require", lambda _body, *keys: None)
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: session)
|
||||
monkeypatch.setattr(routes, "_git_paths_from_body", lambda _body: ["selected.py"])
|
||||
monkeypatch.setattr(
|
||||
workspace_git,
|
||||
"staged_commit_message_prompt",
|
||||
lambda _workspace: {"system_prompt": "system", "user_prompt": "user"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workspace_git,
|
||||
"selected_commit_message_prompt",
|
||||
lambda _workspace, _paths: {
|
||||
"system_prompt": "system",
|
||||
"user_prompt": "user",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_llm_git_commit_message",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda _handler, payload, status=200, **_kwargs: {
|
||||
"status": status,
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
lambda _handler, message, status=400: {
|
||||
"status": status,
|
||||
"payload": {"error": message},
|
||||
},
|
||||
)
|
||||
|
||||
response = getattr(routes, handler_name)(object(), body)
|
||||
|
||||
assert response == {
|
||||
"status": 409,
|
||||
"payload": {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch):
|
||||
"""Gateway chat must reach its worker without inspecting the local Agent."""
|
||||
from api import routes
|
||||
|
||||
session = types.SimpleNamespace(session_id="session-1", profile=None)
|
||||
captured = {}
|
||||
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False)
|
||||
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_runner_enabled", lambda: False)
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("gateway-owned start inspected the WebUI Agent revision")
|
||||
),
|
||||
)
|
||||
|
||||
def start_gateway(_session, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"stream_id": "gateway-stream-1", "session_id": "session-1"}
|
||||
|
||||
monkeypatch.setattr(routes, "_start_chat_stream_for_session", start_gateway)
|
||||
|
||||
response = routes._start_run(
|
||||
session,
|
||||
msg="hello",
|
||||
attachments=[],
|
||||
workspace="/tmp/workspace",
|
||||
model="test-model",
|
||||
model_provider="test-provider",
|
||||
normalized_model=False,
|
||||
source="webui",
|
||||
route="/api/chat/start",
|
||||
)
|
||||
|
||||
assert response["stream_id"] == "gateway-stream-1"
|
||||
assert captured["external_runtime_owned"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "body"),
|
||||
[
|
||||
("_handle_btw", {"session_id": "session-1", "question": "question"}),
|
||||
("_handle_background", {"session_id": "session-1", "prompt": "prompt"}),
|
||||
("_handle_chat_sync", {"session_id": "session-1", "message": "message"}),
|
||||
],
|
||||
)
|
||||
def test_hidden_turn_routes_reject_stale_runtime_before_session_creation(
|
||||
monkeypatch, route, body
|
||||
):
|
||||
"""Hidden-turn endpoints must reject before creation even in gateway mode."""
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"api.runtime_adapter.runtime_adapter_runner_enabled",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda _handler, payload, status=200: {"status": status, "payload": payload},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_session",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("session loaded before stale-runtime barrier")
|
||||
),
|
||||
)
|
||||
|
||||
response = getattr(routes, route)(object(), body)
|
||||
|
||||
assert response["status"] == 409
|
||||
assert response["payload"]["type"] == "agent_runtime_stale"
|
||||
|
||||
|
||||
def test_server_side_turn_rejects_stale_runtime_before_session_acceptance(monkeypatch):
|
||||
"""The direct process-wakeup entrypoint must fail before loading/mutating state."""
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_agent_runtime_barrier_response",
|
||||
lambda **_kwargs: {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_session",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("session loaded before stale-runtime barrier")
|
||||
),
|
||||
)
|
||||
|
||||
response = routes.start_session_turn("session-1", "wake up")
|
||||
|
||||
assert response == {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
"_status": 409,
|
||||
}
|
||||
|
|
@ -120,7 +120,7 @@ def test_audit_reports_runtime_agent_execution_imports():
|
|||
classes = _class_by_id(_run_audit())
|
||||
anchors = _anchors(classes["runtime_agent_execution"])
|
||||
|
||||
assert ("api/streaming.py", "run_agent") in anchors
|
||||
assert ("api/agent_runtime.py", "run_agent") in anchors
|
||||
assert ("api/routes.py", "tools.skills_tool") in anchors
|
||||
assert ("api/streaming.py", "tools.approval") in anchors
|
||||
assert ("api/routes.py", "cron.jobs") in anchors
|
||||
|
|
|
|||
|
|
@ -161,7 +161,10 @@ def test_goal_continuation_decision_emits_status_and_normal_user_prompt(monkeypa
|
|||
assert decision["continuation_prompt"].startswith("[Continuing toward your standing goal]")
|
||||
|
||||
|
||||
def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path):
|
||||
@pytest.mark.parametrize("gateway_owned", [False, True])
|
||||
def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(
|
||||
monkeypatch, tmp_path, gateway_owned
|
||||
):
|
||||
"""POST /api/goal uses GoalManager state and launches the first goal turn."""
|
||||
from api import goals as webui_goals
|
||||
from api import routes
|
||||
|
|
@ -199,6 +202,12 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path
|
|||
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"webui_gateway_chat_enabled",
|
||||
lambda _cfg: gateway_owned,
|
||||
)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_resolve_compatible_session_model_state",
|
||||
|
|
@ -229,6 +238,7 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path
|
|||
assert result["payload"]["stream_id"] == "goal-stream"
|
||||
assert started and started[0]["msg"] == "ship the feature"
|
||||
assert started[0]["model_provider"] == "openai-codex"
|
||||
assert started[0]["external_runtime_owned"] is gateway_owned
|
||||
|
||||
|
||||
def test_goal_endpoint_preserves_response_shape_under_runtime_adapter_flag(monkeypatch, tmp_path):
|
||||
|
|
|
|||
|
|
@ -248,6 +248,60 @@ def test_no_api_key_handoff_summary_persists_fallback_summary(monkeypatch):
|
|||
assert persisted[0]["rounds"] == models.CONVERSATION_ROUND_THRESHOLD
|
||||
|
||||
|
||||
def test_stale_runtime_handoff_summary_returns_typed_409_without_persisting(monkeypatch):
|
||||
"""A stale runtime must remain retryable instead of becoming fallback success."""
|
||||
import api.routes as routes
|
||||
import api.models as models
|
||||
|
||||
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda _handler, payload, status=200, extra_headers=None: {
|
||||
"status": status,
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"count_conversation_rounds",
|
||||
lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"get_cli_session_messages",
|
||||
lambda sid: [
|
||||
{"role": "user", "content": "Need a handoff", "timestamp": 1.0},
|
||||
{"role": "assistant", "content": "Context is ready", "timestamp": 2.0},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_persist_handoff_summary",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("stale runtime persisted a fallback summary")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
|
||||
response = routes._handle_handoff_summary(object(), {"session_id": "stale-handoff"})
|
||||
|
||||
assert response == {
|
||||
"status": 409,
|
||||
"payload": {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_exception_handoff_summary_persists_fallback_summary(monkeypatch):
|
||||
"""Unhandled summary exception should still persist a fallback handoff marker."""
|
||||
import api.routes as routes
|
||||
|
|
|
|||
|
|
@ -166,13 +166,13 @@ def test_streaming_py_imports_has_pending(cleanup_test_sessions):
|
|||
|
||||
|
||||
def test_aiagent_imported_in_streaming(cleanup_test_sessions):
|
||||
"""R2b: api/streaming.py must import AIAgent.
|
||||
"""R2b: api/streaming.py must resolve AIAgent through the runtime guard.
|
||||
When missing, the streaming thread crashed immediately after being spawned.
|
||||
"""
|
||||
src = (REPO_ROOT / "api/streaming.py").read_text()
|
||||
assert "AIAgent" in src, "AIAgent not referenced in api/streaming.py"
|
||||
assert "from run_agent import AIAgent" in src or "import AIAgent" in src, \
|
||||
"AIAgent must be imported in api/streaming.py"
|
||||
assert "get_ai_agent_class" in src, "guarded AIAgent resolver not referenced in api/streaming.py"
|
||||
assert "from api.agent_runtime import" in src and "get_ai_agent_class" in src, \
|
||||
"AIAgent must be resolved through api.agent_runtime in api/streaming.py"
|
||||
|
||||
|
||||
# ── R5: SSE loop did not break on cancel event (Sprint 10 bug) ───────────────
|
||||
|
|
|
|||
|
|
@ -129,6 +129,178 @@ def test_session_compress_requires_session_id(cleanup_test_sessions):
|
|||
assert handler.payload()["error"] == "Missing required field(s): session_id"
|
||||
|
||||
|
||||
def test_session_compress_stale_runtime_returns_typed_409_before_mutation(
|
||||
monkeypatch, cleanup_test_sessions
|
||||
):
|
||||
import api.routes as routes
|
||||
|
||||
sid = _make_session()
|
||||
cleanup_test_sessions.append(sid)
|
||||
loaded_before = Session.load(sid)
|
||||
assert loaded_before is not None
|
||||
before = loaded_before.compact()
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
|
||||
handler = _FakeHandler()
|
||||
_handle_session_compress(handler, {"session_id": sid})
|
||||
|
||||
assert handler.status == 409
|
||||
assert handler.payload() == {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}
|
||||
loaded_after = Session.load(sid)
|
||||
assert loaded_after is not None
|
||||
assert loaded_after.compact() == before
|
||||
|
||||
|
||||
def test_session_compress_start_stale_runtime_returns_409_before_job_creation(
|
||||
monkeypatch, cleanup_test_sessions
|
||||
):
|
||||
import api.routes as routes
|
||||
|
||||
sid = _make_session()
|
||||
cleanup_test_sessions.append(sid)
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS.pop(sid, None)
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
|
||||
handler = _FakeHandler()
|
||||
routes._handle_session_compress_start(handler, {"session_id": sid})
|
||||
|
||||
assert handler.status == 409
|
||||
assert json.loads(handler.wfile.getvalue().decode("utf-8")) == {
|
||||
"error": "restart required",
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
assert sid not in routes._MANUAL_COMPRESSION_JOBS
|
||||
|
||||
|
||||
def test_session_compress_start_reuses_running_job_when_runtime_is_stale(
|
||||
monkeypatch, cleanup_test_sessions
|
||||
):
|
||||
import api.routes as routes
|
||||
|
||||
sid = _make_session()
|
||||
cleanup_test_sessions.append(sid)
|
||||
existing = {
|
||||
"session_id": sid,
|
||||
"focus_topic": "already running",
|
||||
"status": "running",
|
||||
"started_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS[sid] = existing
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
|
||||
handler = _FakeHandler()
|
||||
routes._handle_session_compress_start(handler, {"session_id": sid})
|
||||
|
||||
assert handler.status == 200
|
||||
payload = handler.payload()
|
||||
assert payload["status"] == "running"
|
||||
assert payload["session_id"] == sid
|
||||
assert payload["focus_topic"] == "already running"
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
assert routes._MANUAL_COMPRESSION_JOBS[sid] is existing
|
||||
routes._MANUAL_COMPRESSION_JOBS.pop(sid, None)
|
||||
|
||||
|
||||
def test_session_compress_start_rechecks_job_after_runtime_barrier(
|
||||
monkeypatch, cleanup_test_sessions
|
||||
):
|
||||
import api.routes as routes
|
||||
|
||||
sid = _make_session()
|
||||
cleanup_test_sessions.append(sid)
|
||||
admitted = {
|
||||
"session_id": sid,
|
||||
"focus_topic": "admitted concurrently",
|
||||
"status": "running",
|
||||
"started_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
def barrier_then_admit():
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS[sid] = admitted
|
||||
|
||||
def reject_duplicate_worker(**_kwargs):
|
||||
raise AssertionError("duplicate worker admitted")
|
||||
|
||||
monkeypatch.setattr(routes, "ensure_agent_runtime_current", barrier_then_admit)
|
||||
monkeypatch.setattr(routes.threading, "Thread", reject_duplicate_worker)
|
||||
|
||||
handler = _FakeHandler()
|
||||
routes._handle_session_compress_start(handler, {"session_id": sid})
|
||||
|
||||
assert handler.status == 200
|
||||
assert handler.payload()["status"] == "running"
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS.pop(sid, None)
|
||||
|
||||
|
||||
def test_session_compress_worker_preserves_stale_runtime_taxonomy(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
sid = "stale-worker-session"
|
||||
started_at = time.time()
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS[sid] = {
|
||||
"session_id": sid,
|
||||
"status": "running",
|
||||
"started_at": started_at,
|
||||
"updated_at": started_at,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_handle_session_compress",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
routes.AgentRuntimeChangedError("restart required")
|
||||
),
|
||||
)
|
||||
|
||||
routes._run_manual_compression_job(sid, {"session_id": sid})
|
||||
|
||||
handler = _FakeHandler()
|
||||
routes._handle_session_compress_status(handler, sid)
|
||||
payload = handler.payload()
|
||||
assert handler.status == 200
|
||||
assert payload["ok"] is False
|
||||
assert payload["status"] == "error"
|
||||
assert payload["session_id"] == sid
|
||||
assert payload["error"] == "restart required"
|
||||
assert payload["error_status"] == 409
|
||||
assert payload["type"] == "agent_runtime_stale"
|
||||
assert payload["retryable"] is True
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
routes._MANUAL_COMPRESSION_JOBS.pop(sid, None)
|
||||
|
||||
|
||||
def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
|
||||
created = cleanup_test_sessions
|
||||
original_messages = [
|
||||
|
|
|
|||
|
|
@ -1635,7 +1635,7 @@ class TestUpdateSummaryRouteModelSelection:
|
|||
assert '"update_summary"' not in src
|
||||
assert 'main_runtime=main_runtime' in src
|
||||
assert 'update summary auxiliary model failed; falling back to main model' in src
|
||||
assert 'from run_agent import AIAgent' in src
|
||||
assert 'require_ai_agent_class()' in src
|
||||
|
||||
def test_summary_route_auxiliary_model_uses_active_profile_env(self, monkeypatch, tmp_path):
|
||||
import api.config as cfg
|
||||
|
|
|
|||
Loading…
Reference in a new issue