fix: stop masking runtime failures
This commit is contained in:
+27
-32
@@ -941,18 +941,19 @@ class Consolidator:
|
|||||||
messages_to_summarize = public_history_messages(
|
messages_to_summarize = public_history_messages(
|
||||||
summary_messages if summary_messages is not None else messages
|
summary_messages if summary_messages is not None else messages
|
||||||
)
|
)
|
||||||
|
formatted = MemoryStore._format_messages(messages_to_summarize)
|
||||||
|
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
||||||
|
system_prompt = render_template(
|
||||||
|
"agent/consolidator_archive.md",
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
formatted = MemoryStore._format_messages(messages_to_summarize)
|
|
||||||
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
|
||||||
response = await runtime.provider.chat_with_retry(
|
response = await runtime.provider.chat_with_retry(
|
||||||
model=runtime.model,
|
model=runtime.model,
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": render_template(
|
"content": system_prompt,
|
||||||
"agent/consolidator_archive.md",
|
|
||||||
strip=True,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{"role": "user", "content": formatted},
|
{"role": "user", "content": formatted},
|
||||||
],
|
],
|
||||||
@@ -962,19 +963,21 @@ class Consolidator:
|
|||||||
max_tokens=runtime.generation.max_tokens,
|
max_tokens=runtime.generation.max_tokens,
|
||||||
reasoning_effort=runtime.generation.reasoning_effort,
|
reasoning_effort=runtime.generation.reasoning_effort,
|
||||||
)
|
)
|
||||||
if response.finish_reason == "error":
|
|
||||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
|
||||||
summary = response.content or "[no summary]"
|
|
||||||
self.store.append_history(
|
|
||||||
summary,
|
|
||||||
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
return summary
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
logger.warning("Consolidation provider call failed, raw-dumping to history")
|
||||||
self.store.raw_archive(messages, session_key=session_key)
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
return None
|
return None
|
||||||
|
if response.finish_reason == "error":
|
||||||
|
logger.warning("Consolidation provider returned an error, raw-dumping to history")
|
||||||
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
|
return None
|
||||||
|
summary = response.content or "[no summary]"
|
||||||
|
self.store.append_history(
|
||||||
|
summary,
|
||||||
|
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||||
|
session_key=session_key,
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
async def maybe_consolidate_by_tokens(
|
async def maybe_consolidate_by_tokens(
|
||||||
self,
|
self,
|
||||||
@@ -1007,14 +1010,10 @@ class Consolidator:
|
|||||||
replay_max_messages,
|
replay_max_messages,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
try:
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
session,
|
||||||
session,
|
runtime=runtime,
|
||||||
runtime=runtime,
|
)
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
|
||||||
estimated, source = 0, "error"
|
|
||||||
if estimated <= 0:
|
if estimated <= 0:
|
||||||
self._persist_last_summary(session, last_summary)
|
self._persist_last_summary(session, last_summary)
|
||||||
return
|
return
|
||||||
@@ -1077,14 +1076,10 @@ class Consolidator:
|
|||||||
# the next invocation can retry a fresh chunk.
|
# the next invocation can retry a fresh chunk.
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
session,
|
||||||
session,
|
runtime=runtime,
|
||||||
runtime=runtime,
|
)
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
|
||||||
estimated, source = 0, "error"
|
|
||||||
if estimated <= 0:
|
if estimated <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
+13
-36
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from contextlib import suppress
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -353,37 +352,16 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for iteration in range(spec.max_iterations):
|
for iteration in range(spec.max_iterations):
|
||||||
try:
|
# Keep the persisted conversation untouched. Context governance
|
||||||
# Keep the persisted conversation untouched. Context governance
|
# may repair or compact historical messages for the model, but
|
||||||
# may repair or compact historical messages for the model, but
|
# those synthetic edits must not shift the append boundary used
|
||||||
# those synthetic edits must not shift the append boundary used
|
# later when the caller saves only the new turn. A governance
|
||||||
# later when the caller saves only the new turn.
|
# failure must stop the run instead of sending an ungoverned copy.
|
||||||
messages_for_model = self.context_governor.prepare_for_model(
|
messages_for_model = self.context_governor.prepare_for_model(
|
||||||
governance_config,
|
governance_config,
|
||||||
messages,
|
messages,
|
||||||
compacted_tool_call_ids,
|
compacted_tool_call_ids,
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
|
||||||
iteration,
|
|
||||||
spec.session_key or "default",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
|
||||||
messages
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
messages_for_model = messages
|
|
||||||
context = AgentHookContext(
|
context = AgentHookContext(
|
||||||
iteration=iteration,
|
iteration=iteration,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -1167,10 +1145,9 @@ class AgentRunner:
|
|||||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
prepare_call = getattr(spec.tools, "prepare_call", None)
|
||||||
tool, params, prep_error = None, tool_call.arguments, None
|
tool, params, prep_error = None, tool_call.arguments, None
|
||||||
if callable(prepare_call):
|
if callable(prepare_call):
|
||||||
with suppress(Exception):
|
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
if isinstance(prepared, tuple) and len(prepared) == 3:
|
||||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
tool, params, prep_error = prepared
|
||||||
tool, params, prep_error = prepared
|
|
||||||
if prep_error:
|
if prep_error:
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
|
|||||||
+2
-19
@@ -344,8 +344,6 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
# -- non-streaming path (original logic) --
|
# -- non-streaming path (original logic) --
|
||||||
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
try:
|
try:
|
||||||
@@ -360,24 +358,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
timeout=timeout_s,
|
timeout=timeout_s,
|
||||||
)
|
)
|
||||||
response_text = _response_text(response)
|
response_text = _response_text(response)
|
||||||
|
|
||||||
if not response_text or not response_text.strip():
|
if not response_text or not response_text.strip():
|
||||||
logger.warning("Empty response for session {}, retrying", session_key)
|
logger.warning("Empty response for session {}, using fallback", session_key)
|
||||||
retry_response = await asyncio.wait_for(
|
response_text = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
agent_loop.process_direct(
|
|
||||||
content=text,
|
|
||||||
media=media_paths if media_paths else None,
|
|
||||||
session_key=session_key,
|
|
||||||
channel="api",
|
|
||||||
chat_id=API_CHAT_ID,
|
|
||||||
persist_user_message=False,
|
|
||||||
),
|
|
||||||
timeout=timeout_s,
|
|
||||||
)
|
|
||||||
response_text = _response_text(retry_response)
|
|
||||||
if not response_text or not response_text.strip():
|
|
||||||
logger.warning("Empty response after retry, using fallback")
|
|
||||||
response_text = fallback
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return _error_json(504, f"Request timed out after {timeout_s}s")
|
return _error_json(504, f"Request timed out after {timeout_s}s")
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ class AgentDefaults(Base):
|
|||||||
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
||||||
dream: DreamConfig = Field(default_factory=DreamConfig)
|
dream: DreamConfig = Field(default_factory=DreamConfig)
|
||||||
|
|
||||||
|
@field_validator("timezone")
|
||||||
|
@classmethod
|
||||||
|
def validate_timezone(cls, value: str) -> str:
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
raise ValueError(f"unknown timezone {value!r}") from None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class AgentsConfig(Base):
|
class AgentsConfig(Base):
|
||||||
"""Agent configuration."""
|
"""Agent configuration."""
|
||||||
|
|||||||
@@ -53,36 +53,32 @@ _BUNDLED_FEATURE_ALIASES = {"documents", "pdf"}
|
|||||||
|
|
||||||
|
|
||||||
def load_pyproject(path: Path) -> dict[str, Any]:
|
def load_pyproject(path: Path) -> dict[str, Any]:
|
||||||
try:
|
import tomllib
|
||||||
import tomllib
|
|
||||||
|
|
||||||
return tomllib.loads(path.read_text(encoding="utf-8"))
|
try:
|
||||||
except Exception:
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
|
return tomllib.loads(content)
|
||||||
|
|
||||||
|
|
||||||
def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]:
|
def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]:
|
||||||
try:
|
from importlib.metadata import metadata, requires
|
||||||
from importlib.metadata import metadata, requires
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extras = metadata("nanobot-ai").get_all("Provides-Extra") or []
|
extras = metadata("nanobot-ai").get_all("Provides-Extra") or []
|
||||||
groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"}
|
raw_requirements = requires("nanobot-ai") or []
|
||||||
for raw in requires("nanobot-ai") or []:
|
except PackageNotFoundError:
|
||||||
try:
|
|
||||||
req = Requirement(raw)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if not req.marker:
|
|
||||||
continue
|
|
||||||
for extra, deps in groups.items():
|
|
||||||
if deps is not None and req.marker.evaluate({"extra": extra}):
|
|
||||||
deps.append(raw)
|
|
||||||
return groups
|
|
||||||
except Exception:
|
|
||||||
return {}
|
return {}
|
||||||
|
groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"}
|
||||||
|
for raw in raw_requirements:
|
||||||
|
req = Requirement(raw)
|
||||||
|
if not req.marker:
|
||||||
|
continue
|
||||||
|
for extra, deps in groups.items():
|
||||||
|
if deps is not None and req.marker.evaluate({"extra": extra}):
|
||||||
|
deps.append(raw)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def optional_dependency_groups() -> dict[str, list[str] | None]:
|
def optional_dependency_groups() -> dict[str, list[str] | None]:
|
||||||
@@ -105,11 +101,7 @@ def optional_dependency_groups() -> dict[str, list[str] | None]:
|
|||||||
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
|
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
|
||||||
install_args: list[str] = []
|
install_args: list[str] = []
|
||||||
for raw in deps:
|
for raw in deps:
|
||||||
try:
|
req = Requirement(raw)
|
||||||
req = Requirement(raw)
|
|
||||||
except Exception:
|
|
||||||
install_args.append(raw)
|
|
||||||
continue
|
|
||||||
if req.marker and not req.marker.evaluate({"extra": extra}):
|
if req.marker and not req.marker.evaluate({"extra": extra}):
|
||||||
continue
|
continue
|
||||||
req.marker = None
|
req.marker = None
|
||||||
@@ -168,10 +160,7 @@ def _extra_dependencies_installed(
|
|||||||
|
|
||||||
matched = False
|
matched = False
|
||||||
for raw in dist.requires or []:
|
for raw in dist.requires or []:
|
||||||
try:
|
req = Requirement(raw)
|
||||||
req = Requirement(raw)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
|
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
|
||||||
continue
|
continue
|
||||||
matched = True
|
matched = True
|
||||||
|
|||||||
+23
-12
@@ -43,6 +43,7 @@ _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
|||||||
_SESSION_PREVIEW_MAX_CHARS = 120
|
_SESSION_PREVIEW_MAX_CHARS = 120
|
||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
||||||
|
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
||||||
_FORK_VOLATILE_METADATA_KEYS = {
|
_FORK_VOLATILE_METADATA_KEYS = {
|
||||||
"goal_state",
|
"goal_state",
|
||||||
"pending_user_turn",
|
"pending_user_turn",
|
||||||
@@ -466,7 +467,7 @@ class SessionManager:
|
|||||||
if padding != 4:
|
if padding != 4:
|
||||||
stem += "=" * padding
|
stem += "=" * padding
|
||||||
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
||||||
except Exception:
|
except _SESSION_DATA_ERRORS:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_session_path(self, key: str) -> Path:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
@@ -491,11 +492,13 @@ class SessionManager:
|
|||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("session records must be JSON objects")
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
stored_key = data.get("key")
|
stored_key = data.get("key")
|
||||||
return stored_key if isinstance(stored_key, str) else None
|
return stored_key if isinstance(stored_key, str) else None
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except _SESSION_DATA_ERRORS:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -540,11 +543,8 @@ class SessionManager:
|
|||||||
stored_key,
|
stored_key,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
try:
|
shutil.move(str(fallback_path), str(path))
|
||||||
shutil.move(str(fallback_path), str(path))
|
logger.info("Migrated session {} from {}", key, description)
|
||||||
logger.info("Migrated session {} from {}", key, description)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to migrate session {}", key)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -564,6 +564,8 @@ class SessionManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("session records must be JSON objects")
|
||||||
|
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
@@ -581,7 +583,7 @@ class SessionManager:
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
last_consolidated=last_consolidated
|
last_consolidated=last_consolidated
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to load session {}: {}", key, e)
|
logger.warning("Failed to load session {}: {}", key, e)
|
||||||
repaired = self._repair(key)
|
repaired = self._repair(key)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
@@ -613,6 +615,9 @@ class SessionManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
@@ -640,7 +645,7 @@ class SessionManager:
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
last_consolidated=last_consolidated
|
last_consolidated=last_consolidated
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Repair failed for session {}: {}", key, e)
|
logger.warning("Repair failed for session {}: {}", key, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -842,7 +847,7 @@ class SessionManager:
|
|||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to read session {}: {}", key, e)
|
logger.warning("Failed to read session {}: {}", key, e)
|
||||||
repaired = self._repair(key)
|
repaired = self._repair(key)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
@@ -866,6 +871,8 @@ class SessionManager:
|
|||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("session records must be JSON objects")
|
||||||
if data.get("_type") != "metadata":
|
if data.get("_type") != "metadata":
|
||||||
return None
|
return None
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
@@ -876,7 +883,7 @@ class SessionManager:
|
|||||||
"metadata": metadata if isinstance(metadata, dict) else {},
|
"metadata": metadata if isinstance(metadata, dict) else {},
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to read session metadata {}: {}", key, e)
|
logger.warning("Failed to read session metadata {}: {}", key, e)
|
||||||
repaired = self._repair(key)
|
repaired = self._repair(key)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
@@ -907,6 +914,8 @@ class SessionManager:
|
|||||||
first_line = f.readline().strip()
|
first_line = f.readline().strip()
|
||||||
if first_line:
|
if first_line:
|
||||||
data = json.loads(first_line)
|
data = json.loads(first_line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("session records must be JSON objects")
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or fallback_key
|
key = data.get("key") or fallback_key
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
@@ -926,6 +935,8 @@ class SessionManager:
|
|||||||
):
|
):
|
||||||
break
|
break
|
||||||
item = json.loads(line)
|
item = json.loads(line)
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("session records must be JSON objects")
|
||||||
if item.get("_type") == "metadata":
|
if item.get("_type") == "metadata":
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(item)
|
text = _message_preview_text(item)
|
||||||
@@ -948,7 +959,7 @@ class SessionManager:
|
|||||||
"path": str(path),
|
"path": str(path),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except _SESSION_DATA_ERRORS:
|
||||||
repaired = self._repair(fallback_key, path=path)
|
repaired = self._repair(fallback_key, path=path)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
sessions.append(
|
sessions.append(
|
||||||
|
|||||||
+28
-31
@@ -16,6 +16,10 @@ from loguru import logger
|
|||||||
_WORKING_TREE_DIFF_MAX_CHARS = 6000
|
_WORKING_TREE_DIFF_MAX_CHARS = 6000
|
||||||
|
|
||||||
|
|
||||||
|
class GitStoreError(RuntimeError):
|
||||||
|
"""Raised when the memory Git repository cannot complete an operation."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommitInfo:
|
class CommitInfo:
|
||||||
sha: str # Short SHA (8 chars)
|
sha: str # Short SHA (8 chars)
|
||||||
@@ -125,9 +129,8 @@ class GitStore:
|
|||||||
)
|
)
|
||||||
logger.info("Git store initialized at {}", self._workspace)
|
logger.info("Git store initialized at {}", self._workspace)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git store init failed for {}", self._workspace)
|
raise GitStoreError(f"Git store init failed for {self._workspace}") from exc
|
||||||
return False
|
|
||||||
|
|
||||||
# -- daily operations ------------------------------------------------------
|
# -- daily operations ------------------------------------------------------
|
||||||
|
|
||||||
@@ -161,9 +164,8 @@ class GitStore:
|
|||||||
sha = sha_bytes.hex()[:8]
|
sha = sha_bytes.hex()[:8]
|
||||||
logger.debug("Git auto-commit: {} ({})", sha, message)
|
logger.debug("Git auto-commit: {} ({})", sha, message)
|
||||||
return sha
|
return sha
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git auto-commit failed: {}", message)
|
raise GitStoreError(f"Git auto-commit failed: {message}") from exc
|
||||||
return None
|
|
||||||
|
|
||||||
# -- internal helpers ------------------------------------------------------
|
# -- internal helpers ------------------------------------------------------
|
||||||
|
|
||||||
@@ -190,8 +192,8 @@ class GitStore:
|
|||||||
break
|
break
|
||||||
sha = commit.parents[0] if commit.parents else None
|
sha = commit.parents[0] if commit.parents else None
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
return None
|
raise GitStoreError(f"Git SHA resolution failed: {short_sha}") from exc
|
||||||
|
|
||||||
def _is_inside_git_repo(self) -> bool:
|
def _is_inside_git_repo(self) -> bool:
|
||||||
"""Check if self._workspace is already inside a git repository.
|
"""Check if self._workspace is already inside a git repository.
|
||||||
@@ -268,16 +270,15 @@ class GitStore:
|
|||||||
sha = commit.parents[0] if commit.parents else None
|
sha = commit.parents[0] if commit.parents else None
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git log failed")
|
raise GitStoreError("Git log failed") from exc
|
||||||
return []
|
|
||||||
|
|
||||||
def line_ages(self, file_path: str) -> list[LineAge]:
|
def line_ages(self, file_path: str) -> list[LineAge]:
|
||||||
"""Compute the age of each line in a tracked file via git blame.
|
"""Compute the age of each line in a tracked file via git blame.
|
||||||
|
|
||||||
Returns one LineAge per line, in order.
|
Returns one LineAge per line, in order.
|
||||||
Returns an empty list if the repo is not initialized, the file is
|
Returns an empty list if the repo is not initialized or the file is
|
||||||
empty, or annotation fails.
|
empty. Annotation failures raise :class:`GitStoreError`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.is_initialized():
|
if not self.is_initialized():
|
||||||
@@ -291,9 +292,8 @@ class GitStore:
|
|||||||
from dulwich import porcelain
|
from dulwich import porcelain
|
||||||
|
|
||||||
annotated = porcelain.annotate(str(self._workspace), file_path)
|
annotated = porcelain.annotate(str(self._workspace), file_path)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git line_ages annotate failed for {}", file_path)
|
raise GitStoreError(f"Git line annotation failed for {file_path}") from exc
|
||||||
return []
|
|
||||||
|
|
||||||
if not annotated:
|
if not annotated:
|
||||||
return []
|
return []
|
||||||
@@ -321,9 +321,8 @@ class GitStore:
|
|||||||
outstream=out,
|
outstream=out,
|
||||||
)
|
)
|
||||||
return out.getvalue().decode("utf-8", errors="replace")
|
return out.getvalue().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git diff_commits failed")
|
raise GitStoreError(f"Git diff failed for {sha1}..{sha2}") from exc
|
||||||
return ""
|
|
||||||
|
|
||||||
def summarize_working_tree(self, paths: list[str]) -> str:
|
def summarize_working_tree(self, paths: list[str]) -> str:
|
||||||
"""Structured summary of working-tree changes vs HEAD for *paths*.
|
"""Structured summary of working-tree changes vs HEAD for *paths*.
|
||||||
@@ -354,8 +353,8 @@ class GitStore:
|
|||||||
import difflib
|
import difflib
|
||||||
|
|
||||||
from dulwich.repo import Repo
|
from dulwich.repo import Repo
|
||||||
except ImportError:
|
except ImportError as exc:
|
||||||
return ""
|
raise GitStoreError("Git working-tree summary dependencies are unavailable") from exc
|
||||||
|
|
||||||
summary_lines: list[str] = []
|
summary_lines: list[str] = []
|
||||||
diff_lines: list[str] = []
|
diff_lines: list[str] = []
|
||||||
@@ -409,9 +408,8 @@ class GitStore:
|
|||||||
total_removed += removed
|
total_removed += removed
|
||||||
summary_lines.append(f"{path}: +{added} -{removed}")
|
summary_lines.append(f"{path}: +{added} -{removed}")
|
||||||
diff_lines.extend(hunks)
|
diff_lines.extend(hunks)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git summarize_working_tree failed")
|
raise GitStoreError("Git working-tree summary failed") from exc
|
||||||
return ""
|
|
||||||
|
|
||||||
if changed == 0:
|
if changed == 0:
|
||||||
return ""
|
return ""
|
||||||
@@ -471,9 +469,8 @@ class GitStore:
|
|||||||
diff = self.diff_commits(parent.hex()[:8], c.sha) if parent else ""
|
diff = self.diff_commits(parent.hex()[:8], c.sha) if parent else ""
|
||||||
return c, diff
|
return c, diff
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git show_commit_diff failed")
|
raise GitStoreError(f"Git commit display failed for {short_sha}") from exc
|
||||||
return None
|
|
||||||
|
|
||||||
# -- restore ---------------------------------------------------------------
|
# -- restore ---------------------------------------------------------------
|
||||||
|
|
||||||
@@ -485,7 +482,8 @@ class GitStore:
|
|||||||
is provided, commits outside that history are rejected before any files
|
is provided, commits outside that history are rejected before any files
|
||||||
are changed.
|
are changed.
|
||||||
|
|
||||||
Returns the new commit SHA, or None on failure.
|
Returns the new commit SHA, or ``None`` when the commit cannot be reverted.
|
||||||
|
Repository and filesystem failures raise :class:`GitStoreError`.
|
||||||
"""
|
"""
|
||||||
if not self.is_initialized():
|
if not self.is_initialized():
|
||||||
return None
|
return None
|
||||||
@@ -534,9 +532,8 @@ class GitStore:
|
|||||||
# Commit the restored state
|
# Commit the restored state
|
||||||
msg = f"revert: undo {commit}"
|
msg = f"revert: undo {commit}"
|
||||||
return self.auto_commit(msg)
|
return self.auto_commit(msg)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Git revert failed for {}", commit)
|
raise GitStoreError(f"Git revert failed for {commit}") from exc
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_blob_from_tree(repo, tree, filepath: str) -> str | None:
|
def _read_blob_from_tree(repo, tree, filepath: str) -> str | None:
|
||||||
|
|||||||
+72
-49
@@ -278,11 +278,7 @@ def current_time_str(timezone: str | None = None) -> str:
|
|||||||
"""Return the current time string."""
|
"""Return the current time string."""
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
try:
|
tz = ZoneInfo(timezone) if timezone else None
|
||||||
tz = ZoneInfo(timezone) if timezone else None
|
|
||||||
except (KeyError, Exception):
|
|
||||||
tz = None
|
|
||||||
|
|
||||||
now = datetime.now(tz=tz) if tz else datetime.now().astimezone()
|
now = datetime.now(tz=tz) if tz else datetime.now().astimezone()
|
||||||
offset = now.strftime("%z")
|
offset = now.strftime("%z")
|
||||||
offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
|
offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
|
||||||
@@ -320,8 +316,7 @@ def truncate_text_to_tokens(text: str, max_tokens: int) -> str:
|
|||||||
|
|
||||||
Unlike :func:`truncate_text`, this measures actual tokens, so the cap holds
|
Unlike :func:`truncate_text`, this measures actual tokens, so the cap holds
|
||||||
regardless of language or content (CJK and code cost more tokens per char).
|
regardless of language or content (CJK and code cost more tokens per char).
|
||||||
Falls back to a char-based estimate (~4 chars/token) if tiktoken is
|
Falls back to a conservative UTF-8 byte budget if tiktoken is unavailable.
|
||||||
unavailable.
|
|
||||||
"""
|
"""
|
||||||
if max_tokens <= 0:
|
if max_tokens <= 0:
|
||||||
return text
|
return text
|
||||||
@@ -340,11 +335,23 @@ def truncate_text_to_tokens(text: str, max_tokens: int) -> str:
|
|||||||
return result
|
return result
|
||||||
return enc.decode(tokens[:max_tokens])
|
return enc.decode(tokens[:max_tokens])
|
||||||
except Exception:
|
except Exception:
|
||||||
max_chars = max_tokens * 4
|
if len(text.encode("utf-8")) <= max_tokens:
|
||||||
suffix_chars = len(_TRUNCATED_SUFFIX)
|
return text
|
||||||
if max_chars <= suffix_chars:
|
suffix_bytes = len(_TRUNCATED_SUFFIX.encode("utf-8"))
|
||||||
return text[:max_chars]
|
if max_tokens <= suffix_bytes:
|
||||||
return truncate_text(text, max_chars - suffix_chars)
|
return _truncate_text_to_utf8_bytes(text, max_tokens)
|
||||||
|
body = _truncate_text_to_utf8_bytes(text, max_tokens - suffix_bytes)
|
||||||
|
return body + _TRUNCATED_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_text_to_utf8_bytes(text: str, max_bytes: int) -> str:
|
||||||
|
"""Return the longest code-point prefix within a UTF-8 byte budget."""
|
||||||
|
if max_bytes <= 0:
|
||||||
|
return ""
|
||||||
|
encoded = text.encode("utf-8")
|
||||||
|
if len(encoded) <= max_bytes:
|
||||||
|
return text
|
||||||
|
return encoded[:max_bytes].decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
|
||||||
def recent_message_start_index(
|
def recent_message_start_index(
|
||||||
@@ -569,51 +576,67 @@ def build_assistant_message(
|
|||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def estimate_prompt_tokens(
|
def _estimate_prompt_tokens_with_source(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
) -> int:
|
) -> tuple[int, str]:
|
||||||
"""Estimate prompt tokens with tiktoken.
|
"""Estimate prompt tokens and identify the counter used.
|
||||||
|
|
||||||
Counts all fields that providers send to the LLM: content, tool_calls,
|
Counts all fields that providers send to the LLM: content, tool_calls,
|
||||||
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
||||||
"""
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
parts.append(content)
|
||||||
|
elif isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text":
|
||||||
|
txt = part.get("text", "")
|
||||||
|
if txt:
|
||||||
|
parts.append(txt)
|
||||||
|
|
||||||
|
tc = msg.get("tool_calls")
|
||||||
|
if tc:
|
||||||
|
parts.append(json.dumps(tc, ensure_ascii=False))
|
||||||
|
|
||||||
|
rc = msg.get("reasoning_content")
|
||||||
|
if isinstance(rc, str) and rc:
|
||||||
|
parts.append(rc)
|
||||||
|
|
||||||
|
for key in ("name", "tool_call_id"):
|
||||||
|
value = msg.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
parts.append(value)
|
||||||
|
|
||||||
|
message_payload = "\n".join(parts)
|
||||||
|
per_message_overhead = len(messages) * 4
|
||||||
try:
|
try:
|
||||||
enc = _get_token_encoding()
|
enc = _get_token_encoding()
|
||||||
parts: list[str] = []
|
|
||||||
for msg in messages:
|
|
||||||
content = msg.get("content")
|
|
||||||
if isinstance(content, str):
|
|
||||||
parts.append(content)
|
|
||||||
elif isinstance(content, list):
|
|
||||||
for part in content:
|
|
||||||
if isinstance(part, dict) and part.get("type") == "text":
|
|
||||||
txt = part.get("text", "")
|
|
||||||
if txt:
|
|
||||||
parts.append(txt)
|
|
||||||
|
|
||||||
tc = msg.get("tool_calls")
|
|
||||||
if tc:
|
|
||||||
parts.append(json.dumps(tc, ensure_ascii=False))
|
|
||||||
|
|
||||||
rc = msg.get("reasoning_content")
|
|
||||||
if isinstance(rc, str) and rc:
|
|
||||||
parts.append(rc)
|
|
||||||
|
|
||||||
for key in ("name", "tool_call_id"):
|
|
||||||
value = msg.get(key)
|
|
||||||
if isinstance(value, str) and value:
|
|
||||||
parts.append(value)
|
|
||||||
|
|
||||||
tool_tokens = (
|
tool_tokens = (
|
||||||
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
|
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
|
||||||
)
|
)
|
||||||
|
message_tokens = len(enc.encode(message_payload)) if message_payload else 0
|
||||||
per_message_overhead = len(messages) * 4
|
return message_tokens + tool_tokens + per_message_overhead, "tiktoken"
|
||||||
message_tokens = len(enc.encode("\n".join(parts))) if parts else 0
|
|
||||||
return message_tokens + tool_tokens + per_message_overhead
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
tool_payload = (
|
||||||
|
("\n" if message_payload else "") + json.dumps(tools, ensure_ascii=False)
|
||||||
|
if tools
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
payload = message_payload + tool_payload
|
||||||
|
estimated = len(payload.encode("utf-8"))
|
||||||
|
return estimated + per_message_overhead, "heuristic"
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_prompt_tokens(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Estimate prompt tokens with tiktoken and a conservative byte fallback."""
|
||||||
|
estimated, _ = _estimate_prompt_tokens_with_source(messages, tools)
|
||||||
|
return estimated
|
||||||
|
|
||||||
|
|
||||||
def estimate_message_tokens(message: dict[str, Any]) -> int:
|
def estimate_message_tokens(message: dict[str, Any]) -> int:
|
||||||
@@ -651,7 +674,7 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
|||||||
enc = _get_token_encoding()
|
enc = _get_token_encoding()
|
||||||
return max(4, len(enc.encode(payload)) + 4)
|
return max(4, len(enc.encode(payload)) + 4)
|
||||||
except Exception:
|
except Exception:
|
||||||
return max(4, len(payload) // 4 + 4)
|
return max(4, len(payload.encode("utf-8")) + 4)
|
||||||
|
|
||||||
|
|
||||||
def estimate_prompt_tokens_chain(
|
def estimate_prompt_tokens_chain(
|
||||||
@@ -660,16 +683,16 @@ def estimate_prompt_tokens_chain(
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Estimate prompt tokens via provider counter first, then tiktoken fallback."""
|
"""Estimate prompt tokens via provider, tiktoken, then a byte heuristic."""
|
||||||
provider_counter = getattr(provider, "estimate_prompt_tokens", None)
|
provider_counter = getattr(provider, "estimate_prompt_tokens", None)
|
||||||
if callable(provider_counter):
|
if callable(provider_counter):
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
tokens, source = provider_counter(messages, tools, model)
|
tokens, source = provider_counter(messages, tools, model)
|
||||||
if isinstance(tokens, (int, float)) and tokens > 0:
|
if isinstance(tokens, (int, float)) and tokens > 0:
|
||||||
return int(tokens), str(source or "provider_counter")
|
return int(tokens), str(source or "provider_counter")
|
||||||
estimated = estimate_prompt_tokens(messages, tools)
|
estimated, source = _estimate_prompt_tokens_with_source(messages, tools)
|
||||||
if estimated > 0:
|
if estimated > 0:
|
||||||
return int(estimated), "tiktoken"
|
return int(estimated), source
|
||||||
return 0, "none"
|
return 0, "none"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -758,11 +758,7 @@ def settings_payload(
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset or "default"
|
||||||
try:
|
effective_preset = config.resolve_preset()
|
||||||
effective_preset = config.resolve_preset()
|
|
||||||
except Exception:
|
|
||||||
effective_preset = config.resolve_default_preset()
|
|
||||||
active_preset_name = "default"
|
|
||||||
|
|
||||||
provider_name = (
|
provider_name = (
|
||||||
config.get_provider_name(effective_preset.model, preset=effective_preset)
|
config.get_provider_name(effective_preset.model, preset=effective_preset)
|
||||||
|
|||||||
@@ -260,6 +260,42 @@ class TestConsolidatorArchiveErrorHandling:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert "[RAW]" not in entries[0]["content"]
|
assert "[RAW]" not in entries[0]["content"]
|
||||||
|
|
||||||
|
async def test_archive_propagates_history_write_failure(
|
||||||
|
self, consolidator, mock_provider, runtime
|
||||||
|
):
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="Summary.",
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
consolidator.store.append_history = MagicMock(side_effect=OSError("disk full"))
|
||||||
|
consolidator.store.raw_archive = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="disk full"):
|
||||||
|
await consolidator.archive(
|
||||||
|
[{"role": "user", "content": "important"}],
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
consolidator.store.raw_archive.assert_not_called()
|
||||||
|
|
||||||
|
async def test_archive_propagates_template_failure_without_raw_archive(
|
||||||
|
self, consolidator, mock_provider, runtime, monkeypatch
|
||||||
|
):
|
||||||
|
consolidator.store.raw_archive = MagicMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.agent.memory.render_template",
|
||||||
|
MagicMock(side_effect=RuntimeError("template failed")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="template failed"):
|
||||||
|
await consolidator.archive(
|
||||||
|
[{"role": "user", "content": "important"}],
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_provider.chat_with_retry.assert_not_awaited()
|
||||||
|
consolidator.store.raw_archive.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestConsolidatorTokenBudget:
|
class TestConsolidatorTokenBudget:
|
||||||
async def test_prompt_below_threshold_does_not_consolidate(
|
async def test_prompt_below_threshold_does_not_consolidate(
|
||||||
@@ -276,6 +312,17 @@ class TestConsolidatorTokenBudget:
|
|||||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||||
consolidator.archive.assert_not_called()
|
consolidator.archive.assert_not_called()
|
||||||
|
|
||||||
|
async def test_token_estimation_failure_propagates(self, consolidator, runtime):
|
||||||
|
session = Session(key="test:estimate-failure")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
consolidator.sessions._session_cache[session.key] = session
|
||||||
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
|
side_effect=RuntimeError("counter failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="counter failed"):
|
||||||
|
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||||
|
|
||||||
async def test_estimate_uses_full_unconsolidated_tail(self, consolidator, runtime):
|
async def test_estimate_uses_full_unconsolidated_tail(self, consolidator, runtime):
|
||||||
"""Consolidation pressure must see messages hidden by the replay window."""
|
"""Consolidation pressure must see messages hidden by the replay window."""
|
||||||
session = Session(key="test:full-tail")
|
session = Session(key="test:full-tail")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Tests for GitStore — git-backed version control for memory files."""
|
"""Tests for GitStore — git-backed version control for memory files."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.utils.gitstore import CommitInfo, GitStore
|
from nanobot.utils.gitstore import CommitInfo, GitStore, GitStoreError
|
||||||
|
|
||||||
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
|
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
|
||||||
|
|
||||||
@@ -49,6 +50,11 @@ class TestInit:
|
|||||||
assert len(commits) == 1
|
assert len(commits) == 1
|
||||||
assert "init" in commits[0].message
|
assert "init" in commits[0].message
|
||||||
|
|
||||||
|
def test_init_failure_is_explicit(self, git):
|
||||||
|
with patch("dulwich.porcelain.init", side_effect=OSError("cannot initialize")):
|
||||||
|
with pytest.raises(GitStoreError, match="init failed"):
|
||||||
|
git.init()
|
||||||
|
|
||||||
|
|
||||||
class TestBuildGitignore:
|
class TestBuildGitignore:
|
||||||
def test_subdirectory_dirs(self, git):
|
def test_subdirectory_dirs(self, git):
|
||||||
@@ -97,6 +103,11 @@ class TestAutoCommit:
|
|||||||
git_ready.auto_commit("nothing 2")
|
git_ready.auto_commit("nothing 2")
|
||||||
assert len(git_ready.log()) == 1 # only init commit
|
assert len(git_ready.log()) == 1 # only init commit
|
||||||
|
|
||||||
|
def test_status_failure_is_explicit(self, git_ready):
|
||||||
|
with patch("dulwich.porcelain.status", side_effect=OSError("broken index")):
|
||||||
|
with pytest.raises(GitStoreError, match="auto-commit failed"):
|
||||||
|
git_ready.auto_commit("update")
|
||||||
|
|
||||||
|
|
||||||
class TestLog:
|
class TestLog:
|
||||||
def test_empty_when_not_initialized(self, git):
|
def test_empty_when_not_initialized(self, git):
|
||||||
|
|||||||
@@ -58,17 +58,11 @@ def _make_loop(tmp_path):
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
async def test_runner_uses_raw_messages_when_context_governance_fails():
|
async def test_runner_propagates_context_governance_failure():
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
captured_messages: list[dict] = []
|
provider.chat_with_retry = AsyncMock()
|
||||||
|
|
||||||
async def chat_with_retry(*, messages, **kwargs):
|
|
||||||
captured_messages[:] = messages
|
|
||||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
|
||||||
|
|
||||||
provider.chat_with_retry = chat_with_retry
|
|
||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
initial_messages = [
|
initial_messages = [
|
||||||
@@ -80,16 +74,16 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
|
|||||||
runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign]
|
runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign]
|
||||||
side_effect=RuntimeError("boom")
|
side_effect=RuntimeError("boom")
|
||||||
)
|
)
|
||||||
result = await runner.run(make_run_spec(provider,
|
with pytest.raises(RuntimeError, match="boom"):
|
||||||
initial_messages=initial_messages,
|
await runner.run(make_run_spec(provider,
|
||||||
tools=tools,
|
initial_messages=initial_messages,
|
||||||
model="test-model",
|
tools=tools,
|
||||||
max_iterations=1,
|
model="test-model",
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_iterations=1,
|
||||||
))
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
assert result.final_content == "done"
|
provider.chat_with_retry.assert_not_awaited()
|
||||||
assert captured_messages == initial_messages
|
|
||||||
|
|
||||||
|
|
||||||
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
|
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
|
||||||
|
|||||||
@@ -149,6 +149,30 @@ def _tool_message(result, tool_call_id: str) -> dict:
|
|||||||
][0]
|
][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_propagates_tool_preparation_failure():
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.prepare_call.side_effect = RuntimeError("tool preparation failed")
|
||||||
|
tools.execute = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="tool preparation failed"):
|
||||||
|
await AgentRunner()._run_tool(
|
||||||
|
make_run_spec(
|
||||||
|
MagicMock(),
|
||||||
|
initial_messages=[],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=1,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
),
|
||||||
|
ToolCallRequest(id="call-1", name="demo", arguments={}),
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.execute.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_batches_read_only_tools_before_exclusive_work():
|
async def test_runner_batches_read_only_tools_before_exclusive_work():
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ class TestConsolidationUnaffectedByUnifiedSession:
|
|||||||
|
|
||||||
session = Session(key="unified:default")
|
session = Session(key="unified:default")
|
||||||
session.messages = []
|
session.messages = []
|
||||||
|
sessions.get_or_create.return_value = session
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||||
|
|
||||||
@@ -378,6 +379,7 @@ class TestConsolidationUnaffectedByUnifiedSession:
|
|||||||
|
|
||||||
session = Session(key=key)
|
session = Session(key=key)
|
||||||
session.messages = [] # empty → exits immediately for both keys
|
session.messages = [] # empty → exits immediately for both keys
|
||||||
|
sessions.get_or_create.return_value = session
|
||||||
|
|
||||||
consolidator.archive = AsyncMock()
|
consolidator.archive = AsyncMock()
|
||||||
await consolidator.maybe_consolidate_by_tokens(
|
await consolidator.maybe_consolidate_by_tokens(
|
||||||
|
|||||||
@@ -2508,6 +2508,36 @@ def test_optional_dependency_groups_falls_back_to_package_metadata(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_pyproject_propagates_malformed_toml(tmp_path):
|
||||||
|
from nanobot import optional_features
|
||||||
|
|
||||||
|
path = tmp_path / "pyproject.toml"
|
||||||
|
path.write_text("[project\nname = 'nanobot'", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(tomllib.TOMLDecodeError):
|
||||||
|
optional_features.load_pyproject(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_dependency_metadata_propagates_malformed_requirement(monkeypatch):
|
||||||
|
from packaging.requirements import InvalidRequirement
|
||||||
|
|
||||||
|
from nanobot import optional_features
|
||||||
|
|
||||||
|
class _Metadata:
|
||||||
|
def get_all(self, key: str):
|
||||||
|
assert key == "Provides-Extra"
|
||||||
|
return ["bedrock"]
|
||||||
|
|
||||||
|
monkeypatch.setattr("importlib.metadata.metadata", lambda _name: _Metadata())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"importlib.metadata.requires",
|
||||||
|
lambda _name: ["not a valid requirement ???"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidRequirement):
|
||||||
|
optional_features.optional_dependency_groups_from_metadata()
|
||||||
|
|
||||||
|
|
||||||
def test_install_args_for_extra_resolves_metadata_markers_for_current_platform():
|
def test_install_args_for_extra_resolves_metadata_markers_for_current_platform():
|
||||||
from nanobot import optional_features
|
from nanobot import optional_features
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
|||||||
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_timezone_rejects_unknown_iana_name() -> None:
|
||||||
|
with pytest.raises(ValueError, match="unknown timezone"):
|
||||||
|
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
||||||
|
|
||||||
|
|
||||||
def test_provider_api_type_accepts_exact_values_only() -> None:
|
def test_provider_api_type_accepts_exact_values_only() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@@ -170,3 +170,27 @@ class TestFlushAll:
|
|||||||
assert len(history) == 2
|
assert len(history) == 2
|
||||||
assert history[0]["content"] == "remember this"
|
assert history[0]["content"] == "remember this"
|
||||||
assert history[1]["content"] == "noted"
|
assert history[1]["content"] == "noted"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadErrors:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"operation",
|
||||||
|
("get_or_create", "read_session_file", "read_session_metadata", "list_sessions"),
|
||||||
|
)
|
||||||
|
def test_permission_error_is_not_treated_as_corrupt_data(
|
||||||
|
self,
|
||||||
|
sessions_dir: Path,
|
||||||
|
operation: str,
|
||||||
|
) -> None:
|
||||||
|
writer = SessionManager(workspace=sessions_dir)
|
||||||
|
session = writer.get_or_create("test:permission")
|
||||||
|
session.add_message("user", "must not disappear")
|
||||||
|
writer.save(session)
|
||||||
|
|
||||||
|
reader = SessionManager(workspace=sessions_dir)
|
||||||
|
with patch("builtins.open", side_effect=PermissionError("access denied")):
|
||||||
|
with pytest.raises(PermissionError, match="access denied"):
|
||||||
|
if operation == "list_sessions":
|
||||||
|
reader.list_sessions()
|
||||||
|
else:
|
||||||
|
getattr(reader, operation)("test:permission")
|
||||||
|
|||||||
@@ -421,65 +421,7 @@ async def test_multimodal_remote_image_url_returns_400(aiohttp_client, mock_agen
|
|||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_response_retry_then_success(aiohttp_client) -> None:
|
async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def sometimes_empty(content, session_key="", channel="", chat_id="", **kwargs):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return ""
|
|
||||||
return "recovered response"
|
|
||||||
|
|
||||||
agent = MagicMock()
|
|
||||||
agent.process_direct = sometimes_empty
|
|
||||||
agent._connect_mcp = AsyncMock()
|
|
||||||
agent.close_mcp = AsyncMock()
|
|
||||||
agent._last_usage = {}
|
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
|
||||||
client = await aiohttp_client(app)
|
|
||||||
resp = await client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
headers=AUTH_HEADERS,
|
|
||||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
||||||
)
|
|
||||||
assert resp.status == 200
|
|
||||||
body = await resp.json()
|
|
||||||
assert body["choices"][0]["message"]["content"] == "recovered response"
|
|
||||||
assert call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_response_retry_does_not_duplicate_user_turn(aiohttp_client) -> None:
|
|
||||||
persist_flags = []
|
|
||||||
|
|
||||||
async def record(content, session_key="", channel="", chat_id="", **kwargs):
|
|
||||||
persist_flags.append(kwargs.get("persist_user_message", True))
|
|
||||||
return "" if len(persist_flags) == 1 else "recovered response"
|
|
||||||
|
|
||||||
agent = MagicMock()
|
|
||||||
agent.process_direct = record
|
|
||||||
agent._connect_mcp = AsyncMock()
|
|
||||||
agent.close_mcp = AsyncMock()
|
|
||||||
agent._last_usage = {}
|
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
|
||||||
client = await aiohttp_client(app)
|
|
||||||
resp = await client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
headers=AUTH_HEADERS,
|
|
||||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
||||||
)
|
|
||||||
assert resp.status == 200
|
|
||||||
# first call persists the user turn; the retry must not persist it again
|
|
||||||
assert persist_flags == [True, False]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_response_falls_back(aiohttp_client) -> None:
|
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
call_count = 0
|
call_count = 0
|
||||||
@@ -505,7 +447,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
|
|||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
body = await resp.json()
|
body = await resp.json()
|
||||||
assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE
|
assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
assert call_count == 2
|
assert call_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore, GitStoreError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -63,11 +63,13 @@ class TestLineAges:
|
|||||||
assert len(ages) == 2
|
assert len(ages) == 2
|
||||||
assert all(a.age_days == 30 for a in ages)
|
assert all(a.age_days == 30 for a in ages)
|
||||||
|
|
||||||
def test_annotate_failure_returns_empty(self, tmp_path):
|
def test_annotate_failure_is_explicit(self, git, tmp_path):
|
||||||
"""If annotate fails, line_ages should return [] gracefully."""
|
(tmp_path / "MEMORY.md").write_text("important\n", encoding="utf-8")
|
||||||
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
|
git.auto_commit("initial")
|
||||||
# Don't init — annotate will fail
|
|
||||||
assert git.line_ages("MEMORY.md") == []
|
with patch("dulwich.porcelain.annotate", side_effect=OSError("broken repo")):
|
||||||
|
with pytest.raises(GitStoreError, match="annotation failed"):
|
||||||
|
git.line_ages("MEMORY.md")
|
||||||
|
|
||||||
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
|
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
|
||||||
"""Only modified lines should reflect the new commit's timestamp."""
|
"""Only modified lines should reflect the new commit's timestamp."""
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
import pytest
|
||||||
import tiktoken
|
import tiktoken
|
||||||
|
|
||||||
from nanobot.utils import helpers
|
from nanobot.utils import helpers
|
||||||
from nanobot.utils.helpers import _write_text_atomic, split_message, truncate_text_to_tokens
|
from nanobot.utils.helpers import (
|
||||||
|
_write_text_atomic,
|
||||||
|
current_time_str,
|
||||||
|
split_message,
|
||||||
|
truncate_text_to_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_split_message_no_code_blocks_unchanged():
|
def test_split_message_no_code_blocks_unchanged():
|
||||||
@@ -43,6 +50,11 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text():
|
|||||||
assert truncate_text_to_tokens(text, 0) == text
|
assert truncate_text_to_tokens(text, 0) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_time_str_rejects_unknown_timezone():
|
||||||
|
with pytest.raises(ZoneInfoNotFoundError):
|
||||||
|
current_time_str("Not/AZone")
|
||||||
|
|
||||||
|
|
||||||
def test_write_text_atomic_fsyncs_file_and_parent_directory(
|
def test_write_text_atomic_fsyncs_file_and_parent_directory(
|
||||||
tmp_path: Path, monkeypatch
|
tmp_path: Path, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from nanobot.utils import helpers
|
from nanobot.utils import helpers
|
||||||
from nanobot.utils.helpers import estimate_prompt_tokens, estimate_prompt_tokens_chain
|
from nanobot.utils.helpers import (
|
||||||
|
estimate_message_tokens,
|
||||||
|
estimate_prompt_tokens,
|
||||||
|
estimate_prompt_tokens_chain,
|
||||||
|
truncate_text_to_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _NoCounterProvider:
|
class _NoCounterProvider:
|
||||||
@@ -35,6 +40,57 @@ def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -
|
|||||||
assert source == "tiktoken"
|
assert source == "tiktoken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_prompt_tokens_uses_conservative_fallback_when_tiktoken_fails(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
helpers,
|
||||||
|
"_get_token_encoding",
|
||||||
|
lambda: (_ for _ in ()).throw(RuntimeError("encoding unavailable")),
|
||||||
|
)
|
||||||
|
|
||||||
|
content = "你" * 1_000
|
||||||
|
messages = [{"role": "user", "content": content}]
|
||||||
|
tokens = estimate_prompt_tokens(messages)
|
||||||
|
chain_tokens, source = estimate_prompt_tokens_chain(
|
||||||
|
_NoCounterProvider(),
|
||||||
|
"test-model",
|
||||||
|
messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
actual_tokens = len(helpers.tiktoken.get_encoding("cl100k_base").encode(content)) + 4
|
||||||
|
assert tokens == len(content.encode("utf-8")) + 4
|
||||||
|
assert tokens >= actual_tokens
|
||||||
|
assert chain_tokens == tokens
|
||||||
|
assert source == "heuristic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_message_tokens_uses_utf8_byte_fallback(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
helpers,
|
||||||
|
"_get_token_encoding",
|
||||||
|
lambda: (_ for _ in ()).throw(RuntimeError("encoding unavailable")),
|
||||||
|
)
|
||||||
|
content = "🙂你" * 100
|
||||||
|
|
||||||
|
assert estimate_message_tokens({"role": "user", "content": content}) == (
|
||||||
|
len(content.encode("utf-8")) + 4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_text_to_tokens_uses_utf8_byte_budget_fallback(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
helpers,
|
||||||
|
"_get_token_encoding",
|
||||||
|
lambda: (_ for _ in ()).throw(RuntimeError("encoding unavailable")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = truncate_text_to_tokens("🙂你" * 100, 40)
|
||||||
|
|
||||||
|
assert result.endswith("\n... (truncated)")
|
||||||
|
assert len(result.encode("utf-8")) <= 40
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_prompt_tokens_caches_tools_encoding(monkeypatch) -> None:
|
def test_estimate_prompt_tokens_caches_tools_encoding(monkeypatch) -> None:
|
||||||
helpers._get_token_encoding.cache_clear()
|
helpers._get_token_encoding.cache_clear()
|
||||||
helpers._TOOLS_TOKEN_CACHE.clear()
|
helpers._TOOLS_TOKEN_CACHE.clear()
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
|
|||||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_payload_propagates_preset_resolution_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config = Config()
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_api.load_config", lambda: config)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
Config,
|
||||||
|
"resolve_preset",
|
||||||
|
lambda _self: (_ for _ in ()).throw(RuntimeError("invalid preset")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="invalid preset"):
|
||||||
|
settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
|
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
|
||||||
assert _docs_version("0.2.3") == "0.2.3"
|
assert _docs_version("0.2.3") == "0.2.3"
|
||||||
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
|
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
|
||||||
|
|||||||
Reference in New Issue
Block a user