Merge branch 'simp/r3-06-p2-MOA' into simp/integration3
This commit is contained in:
+141
-242
@@ -46,28 +46,21 @@ def _has_var_kwargs(params) -> bool:
|
||||
|
||||
|
||||
def _accepts_require_checkpoint(fn: Callable[..., Any]) -> bool:
|
||||
"""True if ``fn`` can receive the ``require_checkpoint`` keyword.
|
||||
"""True if ``fn`` can receive the ``require_checkpoint`` keyword (unreadable signatures -> False).
|
||||
|
||||
v2 providers written against the docs example use the bare ``on_pre_compress(self,
|
||||
messages)`` shape; passing the keyword would raise TypeError, which the host would
|
||||
re-raise as a checkpoint failure even though the durable write succeeded.
|
||||
Unreadable signatures conservatively report False.
|
||||
Bare-shape v2 providers (``on_pre_compress(self, messages)``) would raise TypeError on the
|
||||
keyword, which the host would re-raise as a checkpoint failure despite a successful write.
|
||||
"""
|
||||
params = _signature_params(fn)
|
||||
if params is None:
|
||||
return False
|
||||
param = params.get("require_checkpoint")
|
||||
return _has_var_kwargs(params) or (
|
||||
param is not None and param.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
)
|
||||
kind = getattr(params.get("require_checkpoint"), "kind", None)
|
||||
return _has_var_kwargs(params) or kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
|
||||
|
||||
def _ctx_bound(fn: Callable[[], Any]) -> Callable[[], Any]:
|
||||
"""Bind ``fn`` to the CALLER's contextvars for execution on another thread.
|
||||
|
||||
Profile isolation is a ContextVar-scoped HERMES_HOME override; an unbound provider
|
||||
on a worker thread would silently resolve paths/secrets against the default profile.
|
||||
"""
|
||||
"""Bind ``fn`` to the CALLER's contextvars for another thread: profile isolation is a
|
||||
ContextVar-scoped HERMES_HOME override, and an unbound worker would silently use the default profile."""
|
||||
return partial(contextvars.copy_context().run, fn)
|
||||
|
||||
|
||||
@@ -88,10 +81,8 @@ def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
return schema if name and isinstance(name, str) else None
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(
|
||||
enabled_toolsets: Optional[List[str]], disabled_toolsets: Optional[List[str]] = None, *,
|
||||
memory_tool_present: bool = False,
|
||||
) -> bool:
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]], disabled_toolsets: Optional[List[str]] = None,
|
||||
*, memory_tool_present: bool = False) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if disabled_toolsets and "memory" in disabled_toolsets:
|
||||
return False
|
||||
@@ -121,11 +112,9 @@ def memory_provider_tools_exposed(agent: Any) -> bool:
|
||||
never advertises tools absent from the tool surface.
|
||||
"""
|
||||
tools = getattr(agent, "tools", None)
|
||||
return memory_provider_tools_enabled(
|
||||
getattr(agent, "enabled_toolsets", None),
|
||||
getattr(agent, "disabled_toolsets", None),
|
||||
memory_tool_present=isinstance(tools, (list, tuple)) and any(_tool_name(t) == "memory" for t in tools),
|
||||
)
|
||||
present = isinstance(tools, (list, tuple)) and any(_tool_name(t) == "memory" for t in tools)
|
||||
enabled, disabled = getattr(agent, "enabled_toolsets", None), getattr(agent, "disabled_toolsets", None)
|
||||
return memory_provider_tools_enabled(enabled, disabled, memory_tool_present=present)
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
@@ -138,7 +127,8 @@ def inject_memory_provider_tools(agent: Any) -> int:
|
||||
if not memory_provider_tools_exposed(agent):
|
||||
# Say so once: a silent 0 leaves the provider looking "half on" with no clue which
|
||||
# config key (platform_toolsets / disabled_toolsets) gated it.
|
||||
_providers = [p for p in (getattr(memory_manager, "providers", None) or []) if getattr(p, "name", "") != "builtin"]
|
||||
_providers = [p for p in getattr(memory_manager, "providers", None) or []
|
||||
if getattr(p, "name", "") != "builtin"]
|
||||
if _providers:
|
||||
logger.info(
|
||||
"Memory provider(s) %s configured but the 'memory' toolset is "
|
||||
@@ -192,10 +182,9 @@ def sanitize_context(text: str) -> str:
|
||||
class StreamingContextScrubber:
|
||||
"""Stateful scrubber for streaming text whose memory-context spans may straddle deltas.
|
||||
|
||||
``sanitize_context`` needs both tags in one string, so a span split across deltas
|
||||
would leak to the UI. This holds back partial-tag tails between ``feed()`` calls and
|
||||
drops everything inside a span. One scrubber (or ``reset()``) per top-level
|
||||
response; call ``flush()`` at end of stream.
|
||||
``sanitize_context`` needs both tags in one string, so a split span would leak to the UI;
|
||||
this holds back partial-tag tails between ``feed()`` calls and drops span interiors.
|
||||
One scrubber (or ``reset()``) per top-level response; call ``flush()`` at end of stream.
|
||||
"""
|
||||
|
||||
_OPEN_TAG = "<memory-context>"
|
||||
@@ -218,13 +207,16 @@ class StreamingContextScrubber:
|
||||
out: list[str] = []
|
||||
while buf:
|
||||
if self._in_span:
|
||||
idx = buf.lower().find(self._CLOSE_TAG)
|
||||
held = self._max_partial_suffix(buf, self._CLOSE_TAG) # potential partial close tag
|
||||
tag = self._CLOSE_TAG
|
||||
idx = buf.lower().find(tag)
|
||||
held = self._max_partial_suffix(buf, tag) # potential partial close tag
|
||||
else:
|
||||
idx = self._find_boundary_open_tag(buf)
|
||||
held = self._max_pending_open_suffix(buf) or self._max_partial_suffix(buf, self._OPEN_TAG)
|
||||
tag = self._OPEN_TAG
|
||||
idx = self._find_boundary_open_tag(buf)
|
||||
# A complete boundary tag at the buffer end is held until the next char confirms it.
|
||||
n = len(tag)
|
||||
pending = n if buf.lower().endswith(tag) and self._ends_at_block_boundary(buf[:-n]) else 0
|
||||
held = pending or self._max_partial_suffix(buf, tag)
|
||||
if idx == -1:
|
||||
# Hold back the possible partial tag; inside a span the rest is dropped.
|
||||
if not self._in_span:
|
||||
@@ -248,41 +240,26 @@ class StreamingContextScrubber:
|
||||
@staticmethod
|
||||
def _max_partial_suffix(buf: str, tag: str) -> int:
|
||||
"""Length of the longest buf-suffix that is a (case-insensitive) prefix of ``tag``, else 0."""
|
||||
tag_lower = tag.lower()
|
||||
buf_lower = buf.lower()
|
||||
for i in range(min(len(buf_lower), len(tag_lower) - 1), 0, -1):
|
||||
if tag_lower.startswith(buf_lower[-i:]):
|
||||
return i
|
||||
return 0
|
||||
tag_lower, buf_lower = tag.lower(), buf.lower()
|
||||
span = range(min(len(buf_lower), len(tag_lower) - 1), 0, -1)
|
||||
return next((i for i in span if tag_lower.startswith(buf_lower[-i:])), 0)
|
||||
|
||||
def _find_boundary_open_tag(self, buf: str) -> int:
|
||||
"""Find an opening fence only when it starts a block-like span (own line, newline after)."""
|
||||
buf_lower = buf.lower()
|
||||
search_start = 0
|
||||
while True:
|
||||
idx = buf_lower.find(self._OPEN_TAG, search_start)
|
||||
if idx == -1:
|
||||
return -1
|
||||
after_idx = idx + len(self._OPEN_TAG)
|
||||
if self._is_block_boundary(buf, idx) and after_idx < len(buf) and buf[after_idx] in "\r\n":
|
||||
buf_lower, tag_len = buf.lower(), len(self._OPEN_TAG)
|
||||
idx = buf_lower.find(self._OPEN_TAG)
|
||||
while idx != -1:
|
||||
after_idx = idx + tag_len
|
||||
if self._ends_at_block_boundary(buf[:idx]) and after_idx < len(buf) and buf[after_idx] in "\r\n":
|
||||
return idx
|
||||
search_start = idx + 1
|
||||
|
||||
def _max_pending_open_suffix(self, buf: str) -> int:
|
||||
"""Hold a complete boundary tag at the buffer end until the following char confirms it."""
|
||||
if buf.lower().endswith(self._OPEN_TAG) and self._is_block_boundary(buf, len(buf) - len(self._OPEN_TAG)):
|
||||
return len(self._OPEN_TAG)
|
||||
return 0
|
||||
idx = buf_lower.find(self._OPEN_TAG, idx + 1)
|
||||
return -1
|
||||
|
||||
def _ends_at_block_boundary(self, text: str) -> bool:
|
||||
"""Whether emitting ``text`` leaves the stream at a line start (blank tail after the last newline)."""
|
||||
last_newline = text.rfind("\n")
|
||||
if last_newline == -1:
|
||||
return self._at_block_boundary and text.strip() == ""
|
||||
return text[last_newline + 1:].strip() == ""
|
||||
|
||||
def _is_block_boundary(self, buf: str, idx: int) -> bool:
|
||||
return self._at_block_boundary if idx == 0 else self._ends_at_block_boundary(buf[:idx])
|
||||
"""Whether emitting ``text`` leaves the stream at a line start (blank tail after the last newline;
|
||||
no newline at all -> only whitespace and already at a boundary)."""
|
||||
head, sep, tail = text.rpartition("\n")
|
||||
return tail.strip() == "" and (bool(sep) or self._at_block_boundary)
|
||||
|
||||
def _append_visible(self, out: list[str], text: str) -> None:
|
||||
if text:
|
||||
@@ -307,11 +284,6 @@ def build_memory_context_block(raw_context: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _nonblank(text: Any) -> Any:
|
||||
"""Return ``text`` when it has non-whitespace content, else None."""
|
||||
return text if text and text.strip() else None
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
"""Builtin provider (always first) plus at most one external provider.
|
||||
|
||||
@@ -323,11 +295,11 @@ class MemoryManager:
|
||||
self._providers: List[MemoryProvider] = []
|
||||
self._tool_to_provider: Dict[str, MemoryProvider] = {}
|
||||
self._has_external: bool = False
|
||||
self._external_prefetch_timeout = (
|
||||
_EXTERNAL_PREFETCH_TIMEOUT_S if external_prefetch_timeout is None else float(external_prefetch_timeout)
|
||||
)
|
||||
if self._external_prefetch_timeout <= 0:
|
||||
timeout = external_prefetch_timeout
|
||||
timeout = _EXTERNAL_PREFETCH_TIMEOUT_S if timeout is None else float(timeout)
|
||||
if timeout <= 0:
|
||||
raise ValueError("external_prefetch_timeout must be positive")
|
||||
self._external_prefetch_timeout = timeout
|
||||
self._external_prefetch_threads: Dict[str, threading.Thread] = {}
|
||||
self._external_prefetch_lock = threading.Lock()
|
||||
# Single-worker background executor for end-of-turn sync/prefetch, created lazily so
|
||||
@@ -342,15 +314,10 @@ class MemoryManager:
|
||||
"status": "not_started", "abandoned_writes": 0, "abandoned_prefetches": 0, "active_tasks": 0,
|
||||
}
|
||||
|
||||
def _each_provider(
|
||||
self, label: str, call: Callable[[MemoryProvider], Any], *,
|
||||
level: int = logging.DEBUG, providers: Optional[List[MemoryProvider]] = None, exc_info: bool = False,
|
||||
) -> List[Any]:
|
||||
"""Call ``call(provider)`` for each provider, logging and swallowing failures.
|
||||
|
||||
``label`` completes the log line ``Memory provider '<name>' <label>: <exc>``.
|
||||
Returns the successful results in provider order.
|
||||
"""
|
||||
def _each_provider(self, label: str, call: Callable[[MemoryProvider], Any], *, level: int = logging.DEBUG,
|
||||
providers: Optional[List[MemoryProvider]] = None, exc_info: bool = False) -> List[Any]:
|
||||
"""Call ``call(provider)`` per provider, logging+swallowing failures; returns successes in order.
|
||||
``label`` completes the log line ``Memory provider '<name>' <label>: <exc>``."""
|
||||
results: List[Any] = []
|
||||
for provider in self._providers if providers is None else providers:
|
||||
try:
|
||||
@@ -409,10 +376,9 @@ class MemoryManager:
|
||||
|
||||
def build_system_prompt(self) -> str:
|
||||
"""Join every provider's non-empty ``system_prompt_block()`` with blank lines."""
|
||||
blocks = self._each_provider(
|
||||
"system_prompt_block() failed", lambda p: _nonblank(p.system_prompt_block()), level=logging.WARNING,
|
||||
)
|
||||
return "\n\n".join(b for b in blocks if b)
|
||||
blocks = self._each_provider("system_prompt_block() failed", lambda p: p.system_prompt_block(),
|
||||
level=logging.WARNING)
|
||||
return "\n\n".join(b for b in blocks if b and b.strip())
|
||||
|
||||
# A /skill or /bundle turn embeds the whole skill body in the model-facing message;
|
||||
# providers get just the user's instruction (None for a bare invocation).
|
||||
@@ -424,17 +390,13 @@ class MemoryManager:
|
||||
if not clean_query:
|
||||
return ""
|
||||
parts = self._each_provider(
|
||||
"prefetch failed (non-fatal)",
|
||||
lambda p: _nonblank(self._prefetch_provider(p, clean_query, session_id=session_id)),
|
||||
"prefetch failed (non-fatal)", lambda p: self._prefetch_provider(p, clean_query, session_id=session_id),
|
||||
)
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
return "\n\n".join(p for p in parts if p and p.strip())
|
||||
|
||||
def _prefetch_provider(self, provider: MemoryProvider, query: str, *, session_id: str = "") -> str:
|
||||
"""Run one provider's prefetch; external providers are bounded by a timeout.
|
||||
|
||||
A stuck external call keeps running on its daemon thread and the provider is
|
||||
skipped on later turns until it returns.
|
||||
"""
|
||||
"""Run one provider's prefetch; external providers are bounded by a timeout. A stuck external
|
||||
call keeps running on its daemon thread and the provider is skipped on later turns until it returns."""
|
||||
if provider.name == "builtin":
|
||||
return provider.prefetch(query, session_id=session_id)
|
||||
|
||||
@@ -449,11 +411,9 @@ class MemoryManager:
|
||||
thread = threading.Thread(target=_ctx_bound(_run), daemon=True, name=f"memory-prefetch-{provider.name}")
|
||||
with self._external_prefetch_lock:
|
||||
existing = self._external_prefetch_threads.get(provider.name)
|
||||
if existing is not None:
|
||||
if existing.is_alive():
|
||||
logger.debug("Memory provider '%s' prefetch is still running; skipping this turn", provider.name)
|
||||
return ""
|
||||
self._external_prefetch_threads.pop(provider.name, None)
|
||||
if existing is not None and existing.is_alive():
|
||||
logger.debug("Memory provider '%s' prefetch is still running; skipping this turn", provider.name)
|
||||
return ""
|
||||
self._external_prefetch_threads[provider.name] = thread
|
||||
thread.start()
|
||||
|
||||
@@ -473,21 +433,15 @@ class MemoryManager:
|
||||
return result_box.get("value", "")
|
||||
|
||||
def describe_recall(self) -> str:
|
||||
"""Deterministic recall indicator line (e.g. ``"🧠 Provider — recalled 3 memories"``).
|
||||
|
||||
Call right after :meth:`prefetch_all` so the user SEES memory was used regardless
|
||||
of whether the model mentions it; ``""`` when no provider injected memory.
|
||||
"""
|
||||
"""Deterministic recall indicator line (e.g. ``"🧠 Provider — recalled 3 memories"``); ``""`` if none.
|
||||
Call right after :meth:`prefetch_all` so the user SEES memory was used even if the model is silent."""
|
||||
segments: List[str] = []
|
||||
for status in self._each_provider("recall_status failed (non-fatal)", lambda p: p.recall_status()):
|
||||
if status is None:
|
||||
continue
|
||||
if status.count == 1:
|
||||
detail = "recalled 1 memory"
|
||||
elif status.count > 1:
|
||||
detail = f"recalled {status.count} memories"
|
||||
else: # content injected but no discrete count (reflect)
|
||||
detail = "recalled relevant memory"
|
||||
# count <= 0: content injected but no discrete count (reflect)
|
||||
detail = ("recalled 1 memory" if status.count == 1 else f"recalled {status.count} memories"
|
||||
if status.count > 1 else "recalled relevant memory")
|
||||
segments.append(f"{status.glyph} {status.provider_label} — {detail}")
|
||||
return " ".join(segments)
|
||||
|
||||
@@ -497,14 +451,10 @@ class MemoryManager:
|
||||
clean_query = self._strip_skill_scaffolding(query) if providers else None
|
||||
if not clean_query:
|
||||
return
|
||||
self._submit_background(
|
||||
lambda: self._each_provider(
|
||||
"queue_prefetch failed (non-fatal)",
|
||||
lambda p: p.queue_prefetch(clean_query, session_id=session_id),
|
||||
providers=providers,
|
||||
),
|
||||
kind="prefetch",
|
||||
)
|
||||
self._submit_background(lambda: self._each_provider(
|
||||
"queue_prefetch failed (non-fatal)", lambda p: p.queue_prefetch(clean_query, session_id=session_id),
|
||||
providers=providers,
|
||||
), kind="prefetch")
|
||||
|
||||
@staticmethod
|
||||
def _provider_sync_accepts_messages(provider: MemoryProvider) -> bool:
|
||||
@@ -512,15 +462,12 @@ class MemoryManager:
|
||||
params = _signature_params(provider.sync_turn)
|
||||
return params is None or _has_var_kwargs(params) or "messages" in params
|
||||
|
||||
def sync_all(
|
||||
self, user_content: str, assistant_content: str, *,
|
||||
session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None) -> None:
|
||||
"""Sync a completed turn to all providers on the background worker.
|
||||
|
||||
Never inline: a provider's ``sync_turn`` may block for minutes, which kept
|
||||
``run_conversation`` open after the user saw the response. The single worker
|
||||
also serializes writes so turn N lands before turn N+1.
|
||||
Never inline: a provider's ``sync_turn`` may block for minutes, which kept ``run_conversation``
|
||||
open after the user saw the response. The single worker also serializes writes (turn N before N+1).
|
||||
"""
|
||||
providers = list(self._providers)
|
||||
clean_user_content = self._strip_skill_scaffolding(user_content) if providers else None
|
||||
@@ -538,13 +485,21 @@ class MemoryManager:
|
||||
)
|
||||
|
||||
def _submit_background(self, fn, *, kind: str = "write") -> None:
|
||||
"""Queue ``fn`` on the serialized worker and track its durability class.
|
||||
|
||||
Runs under the caller's contextvars (``_ctx_bound``). If the executor is
|
||||
unavailable outside shutdown, run inline — the historical fail-safe.
|
||||
"""
|
||||
"""Queue ``fn`` on the serialized worker (created lazily; None once shutting down) and track its
|
||||
durability class. Runs under the caller's contextvars (``_ctx_bound``). If the executor is
|
||||
unavailable outside shutdown, run inline — the historical fail-safe."""
|
||||
fn = _ctx_bound(fn)
|
||||
executor = self._get_sync_executor()
|
||||
executor = None if self._shutting_down else self._sync_executor
|
||||
if executor is None and not self._shutting_down:
|
||||
with self._sync_executor_lock:
|
||||
if self._sync_executor is None and not self._shutting_down:
|
||||
try:
|
||||
# Daemon workers: a wedged provider must never block interpreter exit.
|
||||
from tools.daemon_pool import DaemonThreadPoolExecutor
|
||||
self._sync_executor = DaemonThreadPoolExecutor(max_workers=1, thread_name_prefix="mem-sync")
|
||||
except Exception as e: # pragma: no cover - resource exhaustion
|
||||
logger.warning("Failed to create memory sync executor: %s", e)
|
||||
executor = self._sync_executor
|
||||
future = None
|
||||
try:
|
||||
# Submit+track atomically with the shutdown snapshot. The callback is attached
|
||||
@@ -562,53 +517,31 @@ class MemoryManager:
|
||||
return
|
||||
if future is not None:
|
||||
future.add_done_callback(self._forget_background_future)
|
||||
else:
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # pragma: no cover - fn guards internally
|
||||
logger.debug("Inline memory background task failed: %s", e)
|
||||
return
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # pragma: no cover - fn guards internally
|
||||
logger.debug("Inline memory background task failed: %s", e)
|
||||
|
||||
def _forget_background_future(self, future: Future) -> None:
|
||||
with self._sync_executor_lock:
|
||||
self._background_futures.pop(future, None)
|
||||
|
||||
def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]:
|
||||
"""Lazily create the single-worker background executor (None once shutting down)."""
|
||||
if self._shutting_down:
|
||||
return None
|
||||
if self._sync_executor is not None:
|
||||
return self._sync_executor
|
||||
with self._sync_executor_lock:
|
||||
if self._sync_executor is None and not self._shutting_down:
|
||||
try:
|
||||
# Daemon workers: a wedged provider must never block interpreter exit.
|
||||
from tools.daemon_pool import DaemonThreadPoolExecutor
|
||||
self._sync_executor = DaemonThreadPoolExecutor(max_workers=1, thread_name_prefix="mem-sync")
|
||||
except Exception as e: # pragma: no cover - resource exhaustion
|
||||
logger.warning("Failed to create memory sync executor: %s", e)
|
||||
return self._sync_executor
|
||||
|
||||
def flush_pending(self, timeout: Optional[float] = None) -> bool:
|
||||
"""Block until queued sync/prefetch work has drained (False on timeout).
|
||||
|
||||
With a single worker, a sentinel task completing proves every earlier task ran.
|
||||
"""
|
||||
With a single worker, a sentinel task completing proves every earlier task ran."""
|
||||
executor = self._sync_executor
|
||||
if executor is None:
|
||||
return True
|
||||
try:
|
||||
executor.submit(lambda: None).result(timeout=timeout)
|
||||
except RuntimeError:
|
||||
return True # executor already shut down — nothing pending
|
||||
except Exception:
|
||||
return False
|
||||
except Exception as e:
|
||||
return isinstance(e, RuntimeError) # executor already shut down — nothing pending
|
||||
return True
|
||||
|
||||
def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""Collect deduplicated tool schemas from all providers.
|
||||
|
||||
Reserved core tool names are skipped: :meth:`add_provider` refuses to route them.
|
||||
"""
|
||||
"""Collect deduplicated tool schemas from all providers; reserved core tool names are
|
||||
skipped because :meth:`add_provider` refuses to route them."""
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
|
||||
schemas: List[Dict[str, Any]] = []
|
||||
@@ -630,7 +563,7 @@ class MemoryManager:
|
||||
return schemas
|
||||
|
||||
def get_all_tool_names(self) -> set:
|
||||
return set(self._tool_to_provider.keys())
|
||||
return set(self._tool_to_provider)
|
||||
|
||||
def has_tool(self, tool_name: str) -> bool:
|
||||
return tool_name in self._tool_to_provider
|
||||
@@ -650,19 +583,15 @@ class MemoryManager:
|
||||
self._each_provider("on_turn_start failed", lambda p: p.on_turn_start(turn_number, message, **kwargs))
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
self._each_provider(
|
||||
"on_session_end failed", lambda p: p.on_session_end(messages), level=logging.WARNING, exc_info=True,
|
||||
)
|
||||
self._each_provider("on_session_end failed", lambda p: p.on_session_end(messages), level=logging.WARNING,
|
||||
exc_info=True)
|
||||
|
||||
def commit_session_boundary_async(
|
||||
self, messages: List[Dict[str, Any]], *,
|
||||
new_session_id: str, parent_session_id: str = "", reason: str = "new_session",
|
||||
) -> None:
|
||||
def commit_session_boundary_async(self, messages: List[Dict[str, Any]], *, new_session_id: str,
|
||||
parent_session_id: str = "", reason: str = "new_session") -> None:
|
||||
"""Queue old-session extraction + provider rebinding as ONE serialized task.
|
||||
|
||||
``on_session_end`` (LLM-bound, seconds) must run strictly BEFORE ``on_session_switch``
|
||||
rebinds provider session state; an ad-hoc thread raced the inline switch and
|
||||
misattributed transcripts. One FIFO task gives immediate return plus ordering.
|
||||
``on_session_end`` (LLM-bound, seconds) must run strictly BEFORE ``on_session_switch`` rebinds
|
||||
provider state; an ad-hoc thread raced the inline switch and misattributed transcripts.
|
||||
"""
|
||||
if not self._providers:
|
||||
return
|
||||
@@ -680,10 +609,8 @@ class MemoryManager:
|
||||
|
||||
self._submit_background(_run)
|
||||
|
||||
def on_session_switch(
|
||||
self, new_session_id: str, *,
|
||||
parent_session_id: str = "", reset: bool = False, rewound: bool = False, **kwargs,
|
||||
) -> None:
|
||||
def on_session_switch(self, new_session_id: str, *, parent_session_id: str = "", reset: bool = False,
|
||||
rewound: bool = False, **kwargs) -> None:
|
||||
"""Notify providers that ``AIAgent.session_id`` rotated without teardown
|
||||
(``/resume``, ``/branch``, ``/reset``, ``/new``, compression). ``rewound=True``
|
||||
(``/undo``): same id, truncated transcript."""
|
||||
@@ -704,57 +631,45 @@ class MemoryManager:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_checkpoint_provider(cls, provider: MemoryProvider, api_version: int) -> bool:
|
||||
version = cls._checkpoint_api_version(provider)
|
||||
return (_LEGACY_PRE_COMPRESS_API_VERSION if version is None else version) >= api_version
|
||||
|
||||
def supports_pre_compress_checkpoint(self, api_version: int = PRE_COMPRESS_CHECKPOINT_API_VERSION) -> bool:
|
||||
"""Return whether an active provider guarantees checkpoint API support."""
|
||||
return any(
|
||||
(version := self._checkpoint_api_version(p)) is not None and version >= api_version
|
||||
for p in self._providers
|
||||
)
|
||||
versions = (self._checkpoint_api_version(p) for p in self._providers)
|
||||
return any(v is not None and v >= api_version for v in versions)
|
||||
|
||||
def on_pre_compress(
|
||||
self, messages: List[Dict[str, Any]], *,
|
||||
evidence_messages: Optional[List[Dict[str, Any]]] = None,
|
||||
require_checkpoint: bool = False,
|
||||
checkpoint_api_version: int = PRE_COMPRESS_CHECKPOINT_API_VERSION,
|
||||
) -> str:
|
||||
def on_pre_compress(self, messages: List[Dict[str, Any]], *,
|
||||
evidence_messages: Optional[List[Dict[str, Any]]] = None, require_checkpoint: bool = False,
|
||||
checkpoint_api_version: int = PRE_COMPRESS_CHECKPOINT_API_VERSION) -> str:
|
||||
"""Notify providers before compression; return their combined summary-prompt text.
|
||||
|
||||
``messages`` is the raw transcript (the v1 contract). ``evidence_messages`` is the
|
||||
host-normalized list handed only to checkpoint (v2+) providers. With
|
||||
``require_checkpoint``, at least one checkpoint provider must succeed — its
|
||||
exception propagates so the caller keeps the uncompressed transcript.
|
||||
``messages`` is the raw v1 transcript; ``evidence_messages`` is the host-normalized list handed
|
||||
only to checkpoint (v2+) providers. With ``require_checkpoint`` at least one checkpoint provider
|
||||
must succeed — its exception propagates so the caller keeps the uncompressed transcript.
|
||||
"""
|
||||
parts = []
|
||||
checkpoint_succeeded = False
|
||||
for provider in self._providers:
|
||||
is_checkpoint_provider = self._is_checkpoint_provider(provider, checkpoint_api_version)
|
||||
provider_messages = messages
|
||||
version = self._checkpoint_api_version(provider)
|
||||
if version is None:
|
||||
version = _LEGACY_PRE_COMPRESS_API_VERSION
|
||||
is_checkpoint_provider = version >= checkpoint_api_version
|
||||
use_evidence = is_checkpoint_provider and evidence_messages is not None
|
||||
provider_messages = evidence_messages if use_evidence else messages
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if is_checkpoint_provider:
|
||||
if evidence_messages is not None:
|
||||
provider_messages = evidence_messages
|
||||
# v1 providers and bare-shape v2 providers never see the signal.
|
||||
if _accepts_require_checkpoint(provider.on_pre_compress):
|
||||
kwargs["require_checkpoint"] = require_checkpoint
|
||||
# v1 providers and bare-shape v2 providers never see the signal.
|
||||
if is_checkpoint_provider and _accepts_require_checkpoint(provider.on_pre_compress):
|
||||
kwargs["require_checkpoint"] = require_checkpoint
|
||||
try:
|
||||
result = provider.on_pre_compress(provider_messages, **kwargs)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
checkpoint_succeeded = checkpoint_succeeded or is_checkpoint_provider
|
||||
except Exception as e:
|
||||
logger.debug("Memory provider '%s' on_pre_compress failed: %s", provider.name, e)
|
||||
if require_checkpoint and is_checkpoint_provider:
|
||||
raise
|
||||
continue
|
||||
checkpoint_succeeded = checkpoint_succeeded or is_checkpoint_provider
|
||||
if require_checkpoint and not checkpoint_succeeded:
|
||||
raise RuntimeError(
|
||||
"No active memory provider completed pre-compress checkpoint "
|
||||
f"API v{checkpoint_api_version}"
|
||||
f"No active memory provider completed pre-compress checkpoint API v{checkpoint_api_version}"
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
@@ -767,9 +682,8 @@ class MemoryManager:
|
||||
accepted = sum(p.kind is not inspect.Parameter.VAR_POSITIONAL for p in params.values())
|
||||
return "positional" if accepted >= 4 else "legacy"
|
||||
|
||||
def on_memory_write(
|
||||
self, action: str, target: str, content: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
def on_memory_write(self, action: str, target: str, content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Notify external providers when the built-in memory tool writes (skips builtin, the source)."""
|
||||
|
||||
def _notify(provider: MemoryProvider) -> None:
|
||||
@@ -781,9 +695,8 @@ class MemoryManager:
|
||||
else:
|
||||
provider.on_memory_write(action, target, content, metadata=dict(metadata or {}))
|
||||
|
||||
self._each_provider(
|
||||
"on_memory_write failed", _notify, providers=[p for p in self._providers if p.name != "builtin"],
|
||||
)
|
||||
external = [p for p in self._providers if p.name != "builtin"]
|
||||
self._each_provider("on_memory_write failed", _notify, providers=external)
|
||||
|
||||
# Actions mirrored to external providers; non-mutating results (errors, staged) are
|
||||
# filtered by ``notify_memory_tool_write`` first.
|
||||
@@ -791,11 +704,8 @@ class MemoryManager:
|
||||
|
||||
@staticmethod
|
||||
def _memory_tool_result_succeeded(result: Any) -> bool:
|
||||
"""True only when the built-in memory tool actually committed a write.
|
||||
|
||||
Fails closed (non-JSON, non-dict, missing ``success``, staged for approval) so
|
||||
providers never mirror a write that did not land.
|
||||
"""
|
||||
"""True only when the built-in memory tool actually committed a write. Fails closed (non-JSON,
|
||||
non-dict, missing ``success``, staged for approval) so providers never mirror a write that did not land."""
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
@@ -803,23 +713,19 @@ class MemoryManager:
|
||||
return False
|
||||
return isinstance(result, dict) and result.get("success") is True and result.get("staged") is not True
|
||||
|
||||
def notify_memory_tool_write(
|
||||
self, tool_result: Any, tool_args: Dict[str, Any], *,
|
||||
build_metadata: Optional[Callable[[], Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
def notify_memory_tool_write(self, tool_result: Any, tool_args: Dict[str, Any], *,
|
||||
build_metadata: Optional[Callable[[], Dict[str, Any]]] = None) -> None:
|
||||
"""Mirror a built-in memory tool call to external providers.
|
||||
|
||||
Gates on a committed write, expands single-op and batched ``operations`` shapes,
|
||||
keeps only mutating actions, and forwards ``old_text`` plus per-op provenance from
|
||||
``build_metadata`` (the loop knows session/task/tool-call identity; we do not).
|
||||
Gates on a committed write, expands single-op and batched ``operations`` shapes, keeps only
|
||||
mutating actions, and forwards ``old_text`` plus provenance from ``build_metadata`` (the loop
|
||||
knows session/task/tool-call identity; we do not).
|
||||
"""
|
||||
if not self._memory_tool_result_succeeded(tool_result):
|
||||
return
|
||||
target = str(tool_args.get("target") or "memory")
|
||||
operations = tool_args.get("operations")
|
||||
if not (isinstance(operations, list) and operations):
|
||||
operations = [tool_args]
|
||||
for op in operations:
|
||||
for op in operations if isinstance(operations, list) and operations else [tool_args]:
|
||||
action = str(op.get("action") or "") if isinstance(op, dict) else ""
|
||||
if action not in self._MIRRORED_MEMORY_ACTIONS:
|
||||
continue
|
||||
@@ -841,9 +747,8 @@ class MemoryManager:
|
||||
def shutdown_all(self) -> None:
|
||||
"""Drain the background executor (bounded), then shut providers down in reverse order."""
|
||||
self._drain_sync_executor()
|
||||
self._each_provider(
|
||||
"shutdown failed", lambda p: p.shutdown(), level=logging.WARNING, providers=list(reversed(self._providers)),
|
||||
)
|
||||
self._each_provider("shutdown failed", lambda p: p.shutdown(), level=logging.WARNING,
|
||||
providers=self._providers[::-1])
|
||||
|
||||
@property
|
||||
def shutdown_drain_state(self) -> Dict[str, Any]:
|
||||
@@ -860,8 +765,7 @@ class MemoryManager:
|
||||
tracked = dict(self._background_futures)
|
||||
self._shutdown_drain_state = {
|
||||
"status": "draining" if executor is not None else "drained",
|
||||
"abandoned_writes": 0,
|
||||
"abandoned_prefetches": 0,
|
||||
"abandoned_writes": 0, "abandoned_prefetches": 0,
|
||||
"active_tasks": sum(not future.done() for future in tracked),
|
||||
}
|
||||
if executor is None:
|
||||
@@ -871,21 +775,17 @@ class MemoryManager:
|
||||
# tracked futures lets the worker run every queued task in order up to the deadline.
|
||||
executor.shutdown(wait=False, cancel_futures=False)
|
||||
_, pending = wait(tuple(tracked), timeout=_SYNC_DRAIN_TIMEOUT_S)
|
||||
if not pending:
|
||||
with self._sync_executor_lock:
|
||||
self._shutdown_drain_state.update(status="drained", active_tasks=0)
|
||||
return
|
||||
|
||||
cancelled = [tracked[future] for future in pending if future.cancel()]
|
||||
active_tasks = len(pending) - len(cancelled)
|
||||
abandoned_prefetches = cancelled.count("prefetch")
|
||||
abandoned_writes = len(cancelled) - abandoned_prefetches
|
||||
|
||||
with self._sync_executor_lock:
|
||||
self._shutdown_drain_state.update(
|
||||
status="timed_out", abandoned_writes=abandoned_writes,
|
||||
status="timed_out" if pending else "drained", abandoned_writes=abandoned_writes,
|
||||
abandoned_prefetches=abandoned_prefetches, active_tasks=active_tasks,
|
||||
)
|
||||
if not pending:
|
||||
return
|
||||
logger.warning(
|
||||
"Memory shutdown drain timed out after %.2fs; abandoning %d queued "
|
||||
"memory write(s) and %d queued prefetch(es); %d active task(s) remain detached",
|
||||
@@ -897,6 +797,5 @@ class MemoryManager:
|
||||
if "hermes_home" not in kwargs:
|
||||
from hermes_constants import get_hermes_home
|
||||
kwargs["hermes_home"] = str(get_hermes_home())
|
||||
self._each_provider(
|
||||
"initialize failed", lambda p: p.initialize(session_id=session_id, **kwargs), level=logging.WARNING,
|
||||
)
|
||||
self._each_provider("initialize failed", lambda p: p.initialize(session_id=session_id, **kwargs),
|
||||
level=logging.WARNING)
|
||||
|
||||
+75
-125
@@ -26,9 +26,7 @@ _MESSAGE_CORE_KEYS = frozenset({"content", "name", "tool_calls", "role"})
|
||||
|
||||
def _sanitize_surrogates(text: str) -> str:
|
||||
"""Replace lone surrogate code points with U+FFFD; no-op when none present."""
|
||||
if _SURROGATE_RE.search(text):
|
||||
return _SURROGATE_RE.sub('\ufffd', text)
|
||||
return text
|
||||
return _SURROGATE_RE.sub('\ufffd', text)
|
||||
|
||||
|
||||
def _strip_non_ascii(text: str) -> str:
|
||||
@@ -39,12 +37,11 @@ def _strip_non_ascii(text: str) -> str:
|
||||
def _fix_str_field(container: Any, key: Any, fix: Callable[[str], str]) -> bool:
|
||||
"""Apply ``fix`` to ``container[key]`` if it is a str; True if it changed."""
|
||||
value = container.get(key) if isinstance(container, dict) else container[key]
|
||||
if isinstance(value, str):
|
||||
fixed = fix(value)
|
||||
if fixed != value:
|
||||
container[key] = fixed
|
||||
return True
|
||||
return False
|
||||
fixed = fix(value) if isinstance(value, str) else value
|
||||
if fixed == value:
|
||||
return False
|
||||
container[key] = fixed
|
||||
return True
|
||||
|
||||
|
||||
def _sanitize_structure(payload: Any, fix: Callable[[str], str]) -> bool:
|
||||
@@ -63,36 +60,25 @@ def _sanitize_structure(payload: Any, fix: Callable[[str], str]) -> bool:
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list, fix: Callable[[str], str], *, deep: bool) -> bool:
|
||||
"""Apply ``fix`` to the string fields of every message dict in-place.
|
||||
|
||||
Covers content / content-part text, name, tool_call arguments, and every non-core
|
||||
top-level str field. ``deep=True`` additionally covers tool_call ids, function names,
|
||||
and NESTED non-core fields (``reasoning_details`` arrays from byte-level reasoning models).
|
||||
"""
|
||||
"""Apply ``fix`` to the string fields of every message dict in-place (content / part text,
|
||||
name, tool_call arguments, non-core top-level str fields). ``deep=True`` adds tool_call ids,
|
||||
function names, and NESTED non-core fields (``reasoning_details`` from byte-level models)."""
|
||||
found = False
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
found |= _fix_str_field(part, "text", fix)
|
||||
else:
|
||||
found |= _fix_str_field(msg, "content", fix)
|
||||
found |= _fix_str_field(msg, "name", fix)
|
||||
parts = [(p, "text") for p in content if isinstance(p, dict)] if isinstance(content, list) else None
|
||||
fields = parts if parts is not None else [(msg, "content")]
|
||||
fields.append((msg, "name"))
|
||||
tool_calls = msg.get("tool_calls")
|
||||
for tc in tool_calls if isinstance(tool_calls, list) else ():
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function")
|
||||
fn_fields = [(fn, "name"), (fn, "arguments")] if isinstance(fn, dict) else []
|
||||
for container, key in [(tc, "id")] + fn_fields:
|
||||
if deep or key == "arguments":
|
||||
found |= _fix_str_field(container, key, fix)
|
||||
for key, value in list(msg.items()):
|
||||
if key in _MESSAGE_CORE_KEYS:
|
||||
continue
|
||||
fn = tc.get("function") if isinstance(tc, dict) else None
|
||||
fields += [(tc, "id")] if deep and isinstance(tc, dict) else []
|
||||
fields += ([(fn, "name")] if deep else []) + [(fn, "arguments")] if isinstance(fn, dict) else []
|
||||
for container, key in fields:
|
||||
found |= _fix_str_field(container, key, fix)
|
||||
for key, value in [kv for kv in msg.items() if kv[0] not in _MESSAGE_CORE_KEYS]:
|
||||
if isinstance(value, str):
|
||||
found |= _fix_str_field(msg, key, fix)
|
||||
elif deep and isinstance(value, (dict, list)):
|
||||
@@ -169,14 +155,12 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
fixed += '}' * max(0, fixed.count('{') - fixed.count('}'))
|
||||
fixed += ']' * max(0, fixed.count('[') - fixed.count(']'))
|
||||
for _ in range(50):
|
||||
if _loads_ok(fixed):
|
||||
break
|
||||
if (fixed.endswith('}') and fixed.count('}') > fixed.count('{')) or (
|
||||
fixed.endswith(']') and fixed.count(']') > fixed.count('[')
|
||||
if _loads_ok(fixed) or not (
|
||||
(fixed.endswith('}') and fixed.count('}') > fixed.count('{'))
|
||||
or (fixed.endswith(']') and fixed.count(']') > fixed.count('['))
|
||||
):
|
||||
fixed = fixed[:-1]
|
||||
else:
|
||||
break
|
||||
fixed = fixed[:-1]
|
||||
|
||||
if _loads_ok(fixed):
|
||||
logger.warning("Repaired malformed tool_call arguments for %s: %s → %s", tool_name, raw_stripped[:80], fixed[:80])
|
||||
@@ -199,12 +183,10 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
|
||||
|
||||
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
|
||||
"""Append a synthetic assistant turn when an interrupted tail is a tool result.
|
||||
|
||||
A transcript ending on a raw ``tool`` message makes the next user message land as
|
||||
``tool → user`` — an alternation violation strict providers (Gemini, Claude) answer by
|
||||
hallucinating a continuation. Mutates in place; True if a closing turn was appended.
|
||||
"""
|
||||
"""Append a synthetic assistant turn when an interrupted tail is a tool result: a transcript
|
||||
ending on a raw ``tool`` message makes the next user message land as ``tool → user``, an
|
||||
alternation violation strict providers (Gemini, Claude) answer by hallucinating a
|
||||
continuation. Mutates in place; True if a closing turn was appended."""
|
||||
last = messages[-1] if messages else None
|
||||
if not isinstance(last, dict) or last.get("role") != "tool":
|
||||
return False
|
||||
@@ -216,12 +198,9 @@ def close_interrupted_tool_sequence(messages: list, final_response: Any = None)
|
||||
|
||||
|
||||
def serialized_messages_bytes(messages: list) -> int:
|
||||
"""Exact serialized byte size of the ``messages`` payload (HTTP 413 recovery).
|
||||
|
||||
A 413 is a BYTE-size error, but the token estimator prices images at a flat cost, so
|
||||
it cannot score recovery from an image-dominated 413. Non-serializable values fall
|
||||
back to ``str()`` so a malformed message can never crash recovery.
|
||||
"""
|
||||
"""Exact serialized byte size of ``messages`` (HTTP 413 is a BYTE-size error the token
|
||||
estimator, pricing images flat, cannot score). Non-serializable values fall back to
|
||||
``str()`` so a malformed message can never crash recovery."""
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return 0
|
||||
try:
|
||||
@@ -236,19 +215,16 @@ _IMAGE_PART_TYPES = {"image_url", "image", "input_image"}
|
||||
def _strip_images_from_messages(messages: list) -> bool:
|
||||
"""Remove image content parts from all messages in-place (server rejected images).
|
||||
|
||||
``tool`` messages and assistant messages carrying ``tool_calls`` whose content was
|
||||
entirely images get a placeholder, NOT deleted (deleting orphans the paired
|
||||
``tool_call_id`` → HTTP 400); other now-empty messages are dropped. Rewritten messages
|
||||
lose their ``api_content`` sidecar (it carries the images being removed).
|
||||
``tool`` / ``tool_calls`` messages left empty get a placeholder, NOT deleted (deleting
|
||||
orphans the paired ``tool_call_id`` → HTTP 400); other now-empty messages are dropped.
|
||||
Rewritten messages lose their ``api_content`` sidecar (it carries the removed images).
|
||||
"""
|
||||
from agent.turn_context import drop_stale_api_content
|
||||
|
||||
found = False
|
||||
to_delete = []
|
||||
for i, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
content = msg.get("content") if isinstance(msg, dict) else None
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
new_parts = [p for p in content if not (isinstance(p, dict) and p.get("type") in _IMAGE_PART_TYPES)]
|
||||
@@ -326,10 +302,7 @@ def _tc_field(tc: Any, key: str) -> Any:
|
||||
|
||||
|
||||
def _tc_set(tc: Any, key: str, value: Any) -> None:
|
||||
if isinstance(tc, dict):
|
||||
tc[key] = value
|
||||
else:
|
||||
setattr(tc, key, value)
|
||||
tc.__setitem__(key, value) if isinstance(tc, dict) else setattr(tc, key, value)
|
||||
|
||||
|
||||
def deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
|
||||
@@ -344,10 +317,8 @@ def _expand_tool_id_variants(values: tuple[Any, ...]) -> frozenset[str]:
|
||||
variants: set[str] = set()
|
||||
for raw in values:
|
||||
value = raw.strip() if isinstance(raw, str) else ""
|
||||
if not value:
|
||||
continue
|
||||
variants.add(value)
|
||||
if "|" in value:
|
||||
if value:
|
||||
variants.add(value)
|
||||
variants.update(p for p in (part.strip() for part in value.split("|")) if p)
|
||||
return frozenset(variants)
|
||||
|
||||
@@ -363,11 +334,9 @@ def tool_result_id_variants(tool_call_id: Any) -> frozenset[str]:
|
||||
|
||||
|
||||
def coalesce_tool_call_id(tc: Any) -> str:
|
||||
"""Effective call id of a tool_call entry (dict or object); ``""`` when none.
|
||||
|
||||
Codex Responses calls carry ``call_id`` (authoritative pairing key), Chat Completions
|
||||
carry ``id`` only, and bridge ids may be ``call_id|response_item_id``.
|
||||
"""
|
||||
"""Effective call id of a tool_call entry (dict or object); ``""`` when none. Codex Responses
|
||||
carry ``call_id`` (authoritative pairing key), Chat Completions ``id`` only, and bridge ids
|
||||
may be ``call_id|response_item_id``."""
|
||||
for raw in (_tc_field(tc, "call_id"), _tc_field(tc, "id")):
|
||||
value = raw.strip() if isinstance(raw, str) else ""
|
||||
if value:
|
||||
@@ -379,10 +348,10 @@ def uniquify_tool_call_ids(tool_calls: list) -> list:
|
||||
"""Ensure every tool call in one assistant turn has a distinct id.
|
||||
|
||||
Some providers reuse one id across a batch; the pre-API sanitizer then keeps only the
|
||||
first call/result pair per id and strict providers reject duplicates. First occurrence
|
||||
keeps its id; later collisions get a deterministic ``<id>_d<n>`` suffix (never uuid4 —
|
||||
cache-prefix stability). Mutates entries in place (SDK models / SimpleNamespace /
|
||||
dicts). Blank ids are left for the deterministic fallback in ``build_assistant_message``.
|
||||
first call/result pair per id and strict providers reject duplicates. Later collisions
|
||||
get a deterministic ``<id>_d<n>`` suffix (never uuid4 — cache-prefix stability). Mutates
|
||||
entries (SDK models / SimpleNamespace / dicts) in place. Blank ids are left for the
|
||||
deterministic fallback in ``build_assistant_message``.
|
||||
"""
|
||||
seen: set = set()
|
||||
for tc in tool_calls or []:
|
||||
@@ -396,18 +365,14 @@ def uniquify_tool_call_ids(tool_calls: list) -> list:
|
||||
if cid not in seen:
|
||||
seen.add(cid)
|
||||
continue
|
||||
n = 2
|
||||
while f"{cid}_d{n}" in seen:
|
||||
n += 1
|
||||
new_id = f"{cid}_d{n}"
|
||||
# range is bounded: at most len(seen) suffixes can already be taken.
|
||||
new_id = next(f"{cid}_d{n}" for n in range(2, len(seen) + 3) if f"{cid}_d{n}" not in seen)
|
||||
seen.add(new_id)
|
||||
|
||||
def _renamed(value):
|
||||
# Keep a composite id's response-item half so the provider's fc_/item id survives.
|
||||
return f"{new_id}|{value.split('|', 1)[1]}" if isinstance(value, str) and "|" in value else new_id
|
||||
|
||||
try:
|
||||
_tc_set(tc, "id", _renamed(_tc_field(tc, "id")))
|
||||
# Keep a composite id's response-item half so the provider's fc_/item id survives.
|
||||
old = _tc_field(tc, "id")
|
||||
_tc_set(tc, "id", f"{new_id}|{old.split('|', 1)[1]}" if isinstance(old, str) and "|" in old else new_id)
|
||||
if _tc_field(tc, "call_id"):
|
||||
_tc_set(tc, "call_id", new_id)
|
||||
except Exception:
|
||||
@@ -415,22 +380,17 @@ def uniquify_tool_call_ids(tool_calls: list) -> list:
|
||||
continue
|
||||
_fn_name = _tc_field(_tc_field(tc, "function"), "name") or "?"
|
||||
logger.warning(
|
||||
"Model reused tool call id %s within one turn; renamed the "
|
||||
"duplicate to %s (tool=%s) to keep call/result pairing "
|
||||
"lossless.", cid, new_id, _fn_name,
|
||||
"Model reused tool call id %s within one turn; renamed the duplicate to %s (tool=%s) to keep "
|
||||
"call/result pairing lossless.", cid, new_id, _fn_name,
|
||||
)
|
||||
return tool_calls
|
||||
|
||||
|
||||
# -- reasoning_content policy: single owner of strip-vs-re-pad; adapters keep only SYNTAX --
|
||||
# require-side (echo-back enforced; replays 400 without the field):
|
||||
# kimi — provider kimi-coding/kimi-coding-cn, or host api.kimi.com / moonshot.ai /
|
||||
# moonshot.cn. Host-driven on purpose: aggregators re-exporting kimi reject it.
|
||||
# deepseek — provider "deepseek", model contains "deepseek", or host api.deepseek.com.
|
||||
# V4 rejects empty-string pads → " " single space.
|
||||
# mimo — provider "xiaomi", model contains "mimo", or host *.xiaomimimo.com.
|
||||
# strict side (field rejected 400/422 "Extra inputs are not permitted"): everyone else —
|
||||
# Mistral, Cerebras, Groq, SambaNova, … Strip the key entirely, even a one-space pad.
|
||||
# Require side (echo-back enforced; replays 400 without the field): the families below. Kimi
|
||||
# is host-driven on purpose (aggregators re-exporting kimi reject it); DeepSeek V4 rejects
|
||||
# empty-string pads → " ". Strict side (400/422 "Extra inputs are not permitted"): everyone
|
||||
# else — Mistral, Cerebras, Groq, SambaNova, … Strip the key entirely, even a one-space pad.
|
||||
|
||||
_REASONING_ECHO_RULES: tuple = (
|
||||
# (family, exact providers (raw), exact providers (lowered), model substrings (lowered), hosts)
|
||||
@@ -449,20 +409,16 @@ def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_u
|
||||
_, raw_providers, lowered_providers, model_subs, hosts = _REASONING_ECHO_RULE_BY_FAMILY[family]
|
||||
model_lower = (model or "").lower()
|
||||
return (
|
||||
provider in raw_providers
|
||||
or (provider or "").lower() in lowered_providers
|
||||
or any(sub in model_lower for sub in model_subs)
|
||||
or any(base_url_host_matches(base_url, host) for host in hosts)
|
||||
provider in raw_providers or (provider or "").lower() in lowered_providers
|
||||
or any(sub in model_lower for sub in model_subs) or any(base_url_host_matches(base_url, host) for host in hosts)
|
||||
)
|
||||
|
||||
|
||||
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> "str | None":
|
||||
"""``"kimi"`` / ``"deepseek"`` / ``"mimo"`` (first match in table order) when the
|
||||
endpoint enforces reasoning_content echo-back, else ``None`` (strip side)."""
|
||||
return next(
|
||||
(rule[0] for rule in _REASONING_ECHO_RULES if matches_reasoning_echo_family(rule[0], provider, model, base_url)),
|
||||
None,
|
||||
)
|
||||
families = (rule[0] for rule in _REASONING_ECHO_RULES)
|
||||
return next((f for f in families if matches_reasoning_echo_family(f, provider, model, base_url)), None)
|
||||
|
||||
|
||||
def needs_reasoning_echo(provider: Any, model: Any, base_url: Any) -> bool:
|
||||
@@ -471,14 +427,12 @@ def needs_reasoning_echo(provider: Any, model: Any, base_url: Any) -> bool:
|
||||
|
||||
|
||||
def stale_thinking_reaches_wire(api_mode: Any, provider: Any, model: Any, base_url: Any) -> bool:
|
||||
"""True when stale assistant ``reasoning``/``reasoning_content`` text is actually replayed
|
||||
on the wire for the active route.
|
||||
"""True when stale assistant reasoning text is actually replayed on the wire for the route.
|
||||
|
||||
The single wire-truth predicate the compaction TRIGGER estimator and the tail-budget
|
||||
walks must share: if they disagree, a reasoning-heavy session can look over-threshold
|
||||
to preflight yet fully tail-protected to the walk — an infinite compaction loop.
|
||||
``codex_responses`` never reads the text keys (continuity rides the encrypted sidecar);
|
||||
echo-back families replay stored ``reasoning_content`` verbatim; everyone else strips.
|
||||
``codex_responses`` never reads the text keys (continuity rides the encrypted sidecar).
|
||||
"""
|
||||
return (api_mode or "") != "codex_responses" and needs_reasoning_echo(provider, model, base_url)
|
||||
|
||||
@@ -494,41 +448,37 @@ def apply_reasoning_content_policy(source_msg: dict, api_msg: dict, needs_thinki
|
||||
# non-string value (None after compaction): never pass null to the API.
|
||||
api_msg.pop("reasoning_content", None)
|
||||
return
|
||||
existing = source_msg.get("reasoning_content")
|
||||
existing, reasoning = source_msg.get("reasoning_content"), source_msg.get("reasoning")
|
||||
if isinstance(existing, str):
|
||||
# Explicit value: preserve verbatim, upgrading legacy "" to " " (DeepSeek V4 400s on "").
|
||||
api_msg["reasoning_content"] = existing or " "
|
||||
return
|
||||
reasoning = source_msg.get("reasoning")
|
||||
if isinstance(reasoning, str) and reasoning and not source_msg.get("tool_calls"):
|
||||
elif isinstance(reasoning, str) and reasoning and not source_msg.get("tool_calls"):
|
||||
# Healthy session: promote internal 'reasoning' → 'reasoning_content'.
|
||||
api_msg["reasoning_content"] = reasoning
|
||||
return
|
||||
# tool_calls + 'reasoning' but no 'reasoning_content' means the reasoning came from
|
||||
# ANOTHER provider (DeepSeek's own build pins reasoning_content for tool-call turns):
|
||||
# pad without leaking foreign CoT. No reasoning at all: every assistant turn still needs
|
||||
# the field; " " (not "") because DeepSeek V4 rejects empty string.
|
||||
api_msg["reasoning_content"] = " "
|
||||
else:
|
||||
# tool_calls + 'reasoning' but no 'reasoning_content' means the reasoning came from
|
||||
# ANOTHER provider (DeepSeek's own build pins reasoning_content for tool-call turns):
|
||||
# pad without leaking foreign CoT. No reasoning at all: every assistant turn still needs
|
||||
# the field; " " (not "") because DeepSeek V4 rejects empty string.
|
||||
api_msg["reasoning_content"] = " "
|
||||
|
||||
|
||||
def reapply_reasoning_echo(api_messages: list, needs_thinking_pad: bool) -> int:
|
||||
"""Re-pad (or strip) assistant turns' reasoning_content for the ACTIVE provider.
|
||||
|
||||
``api_messages`` is built once under the primary provider; a mid-conversation
|
||||
fallback can switch providers, so baked-in fields must be reconciled: TO a
|
||||
require-side provider re-applies the pad (else 400), TO a strict provider strips it
|
||||
(else 422). Idempotent. Returns the number of assistant turns changed.
|
||||
``api_messages`` is built once under the primary provider; a mid-conversation fallback
|
||||
can switch providers, so baked-in fields must be reconciled: TO a require-side provider
|
||||
re-applies the pad (else 400), TO a strict one strips it (else 422). Idempotent.
|
||||
Returns the number of assistant turns changed.
|
||||
"""
|
||||
changed = 0
|
||||
for api_msg in api_messages:
|
||||
if api_msg.get("role") != "assistant":
|
||||
continue
|
||||
if needs_thinking_pad:
|
||||
if api_msg.get("reasoning_content"):
|
||||
continue
|
||||
apply_reasoning_content_policy(api_msg, api_msg, needs_thinking_pad)
|
||||
if api_msg.get("reasoning_content"):
|
||||
changed += 1
|
||||
if not api_msg.get("reasoning_content"):
|
||||
apply_reasoning_content_policy(api_msg, api_msg, needs_thinking_pad)
|
||||
changed += 1 if api_msg.get("reasoning_content") else 0
|
||||
elif "reasoning_content" in api_msg:
|
||||
api_msg.pop("reasoning_content", None)
|
||||
changed += 1
|
||||
|
||||
+77
-142
@@ -37,60 +37,49 @@ class MicroCompactionMixin:
|
||||
"""Rolling micro-compaction; host must be a ``ContextCompressor``."""
|
||||
|
||||
def _resolve_compact_cursor(self, messages: List[Dict[str, Any]], head_end: int, tail_start: int) -> int:
|
||||
"""Index of the first message not yet absorbed into the rolling summary.
|
||||
|
||||
Uses the in-memory cursor when valid; otherwise scans for the last summary marker.
|
||||
"""
|
||||
"""Index of the first message not yet absorbed into the rolling summary: the in-memory
|
||||
cursor when valid, else just past the last summary marker."""
|
||||
if head_end < self._micro_compact_cursor < tail_start:
|
||||
return self._micro_compact_cursor
|
||||
last_summary_idx = max(
|
||||
(idx for idx in range(head_end, tail_start) if self._is_context_summary_message(messages[idx])),
|
||||
default=-1,
|
||||
)
|
||||
cursor = head_end
|
||||
if last_summary_idx >= head_end:
|
||||
cursor = last_summary_idx + 1
|
||||
summaries = (i for i in range(head_end, tail_start) if self._is_context_summary_message(messages[i]))
|
||||
last = max(summaries, default=-1)
|
||||
cursor = last + 1 if last >= head_end else head_end
|
||||
if last >= head_end:
|
||||
# Resumed session: rehydrate the rolling summary from the surviving marker so the next
|
||||
# pass merges, not replaces.
|
||||
recovered = "" if self._micro_compact_rolling_summary.strip() else (
|
||||
self._rolling_summary_from_marker(messages[last_summary_idx].get("content"))
|
||||
self._rolling_summary_from_marker(messages[last].get("content"))
|
||||
)
|
||||
if recovered:
|
||||
self._micro_compact_rolling_summary = recovered
|
||||
# Rehydration proves containment: this marker (batch or micro) becomes
|
||||
# supersede/defrag-eligible; unabsorbed markers never get the key.
|
||||
messages[last_summary_idx][_cc().MICRO_COMPACT_MARKER_KEY] = True
|
||||
logger.info(
|
||||
"Micro-compaction: recovered rolling summary from "
|
||||
"transcript (%d chars)", len(recovered),
|
||||
)
|
||||
messages[last][_cc().MICRO_COMPACT_MARKER_KEY] = True
|
||||
logger.info("Micro-compaction: recovered rolling summary from transcript (%d chars)", len(recovered))
|
||||
self._micro_compact_cursor = cursor
|
||||
return cursor
|
||||
|
||||
def _find_one_exchange(
|
||||
self, messages: List[Dict[str, Any]], start: int, tail_start: int,
|
||||
) -> Optional[tuple[int, int]]:
|
||||
"""Find the next complete exchange (full agent turn) starting at *start*.
|
||||
|
||||
Returns ``(exchange_start, exchange_end)`` or ``None``. Spans assistant+tool rows up to the
|
||||
next user message; user turns are never absorbed (alternation safety, verbatim user text).
|
||||
"""
|
||||
"""Find the next complete exchange (full agent turn) starting at *start*; returns
|
||||
``(exchange_start, exchange_end)`` or ``None``. Spans assistant+tool rows up to the next
|
||||
user message; user turns are never absorbed (alternation safety, verbatim user text)."""
|
||||
limit = min(tail_start, len(messages))
|
||||
|
||||
def _real_assistant(msg: Dict[str, Any]) -> bool:
|
||||
return msg.get("role") == "assistant" and not self._is_context_summary_message(msg)
|
||||
def _turn_row(idx: int, roles: tuple) -> bool:
|
||||
return messages[idx].get("role") in roles and not self._is_context_summary_message(messages[idx])
|
||||
|
||||
# Skip user messages and (assistant-role) summary markers to reach a real assistant message;
|
||||
# otherwise a rehydrated cursor could absorb the marker itself.
|
||||
idx = start
|
||||
while idx < limit and not _real_assistant(messages[idx]):
|
||||
while idx < limit and not _turn_row(idx, ("assistant",)):
|
||||
idx += 1
|
||||
if idx >= limit:
|
||||
return None
|
||||
|
||||
exchange_start = idx
|
||||
idx += 1
|
||||
while idx < limit and messages[idx].get("role") in ("assistant", "tool") and not self._is_context_summary_message(messages[idx]):
|
||||
while idx < limit and _turn_row(idx, ("assistant", "tool")):
|
||||
idx += 1
|
||||
|
||||
# Boundary must close the turn: a mid-turn stop at tail_start would put the assistant marker
|
||||
@@ -138,11 +127,8 @@ class MicroCompactionMixin:
|
||||
call_kwargs["model"] = self.summary_model
|
||||
if self.model:
|
||||
call_kwargs.setdefault("main_runtime", {
|
||||
"model": self.model,
|
||||
"provider": self.provider or "",
|
||||
"base_url": self.base_url or "",
|
||||
"api_key": self.api_key or "",
|
||||
"api_mode": getattr(self, "api_mode", "") or "",
|
||||
"model": self.model, "provider": self.provider or "", "base_url": self.base_url or "",
|
||||
"api_key": self.api_key or "", "api_mode": getattr(self, "api_mode", "") or "",
|
||||
})
|
||||
|
||||
try:
|
||||
@@ -155,8 +141,7 @@ class MicroCompactionMixin:
|
||||
# A length stop means a partial merge; leave the exchange unabsorbed so a later pass retries.
|
||||
if _cc()._response_finish_reason(response) == "length":
|
||||
logger.warning(
|
||||
"micro-summarization output hit the token cap "
|
||||
"(finish_reason=length) — discarding partial summary",
|
||||
"micro-summarization output hit the token cap (finish_reason=length) — discarding partial summary",
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -176,33 +161,29 @@ class MicroCompactionMixin:
|
||||
|
||||
def _defrag_rolling_summary(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Re-summarize the rolling summary text and rewrite the marker in place.
|
||||
|
||||
Transcript-shape-neutral (no splice, no cursor move). Returns True when it rewrote.
|
||||
"""
|
||||
Transcript-shape-neutral (no splice, no cursor move). Returns True when it rewrote."""
|
||||
old_summary = self._micro_compact_rolling_summary
|
||||
if not old_summary.strip():
|
||||
return False
|
||||
# Empty base turns the merge prompt into a rewrite-compactly instruction.
|
||||
self._micro_compact_rolling_summary = ""
|
||||
fresh_summary = self._micro_summarize_one(old_summary)
|
||||
self._micro_compact_rolling_summary = fresh_summary or old_summary
|
||||
if not fresh_summary:
|
||||
self._micro_compact_rolling_summary = old_summary
|
||||
return False
|
||||
self._micro_compact_rolling_summary = fresh_summary
|
||||
# Rewrite only the newest MICRO marker (resume rehydrates from it); a batch marker holds
|
||||
# history we lack.
|
||||
for entry in reversed(messages):
|
||||
if _is_micro_marker(entry):
|
||||
entry["content"] = self._render_micro_marker_content(fresh_summary)
|
||||
# Content changed: clear the persisted stamp so the DB sync rewrites the row. An
|
||||
# in-place pop on a live dict would be identity-skipped by the bounded flush scan;
|
||||
# flag the finalizer.
|
||||
entry.pop(_cc()._DB_PERSISTED_MARKER, None)
|
||||
self._flush_scan_cursor_invalidated = True
|
||||
break
|
||||
entry = next((e for e in reversed(messages) if _is_micro_marker(e)), None)
|
||||
if entry is not None:
|
||||
entry["content"] = self._render_micro_marker_content(fresh_summary)
|
||||
# Content changed: clear the persisted stamp so the DB sync rewrites the row. An
|
||||
# in-place pop on a live dict would be identity-skipped by the bounded flush scan;
|
||||
# flag the finalizer.
|
||||
entry.pop(_cc()._DB_PERSISTED_MARKER, None)
|
||||
self._flush_scan_cursor_invalidated = True
|
||||
logger.info(
|
||||
"Micro-compaction defrag: rolling summary re-summarized "
|
||||
"(%d -> %d chars)", len(old_summary), len(fresh_summary),
|
||||
"Micro-compaction defrag: rolling summary re-summarized (%d -> %d chars)",
|
||||
len(old_summary), len(fresh_summary),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -211,11 +192,9 @@ class MicroCompactionMixin:
|
||||
self._micro_compact_last_failure_cursor = -1
|
||||
|
||||
def _micro_compact(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Run one round of micro-compaction; public entry point from ``finalize_turn()``.
|
||||
|
||||
Returns the (possibly modified) list and syncs the session DB via ``archive_and_compact``
|
||||
(the append-only flush alone would double-load on resume).
|
||||
"""
|
||||
"""Run one round of micro-compaction (entry point from ``finalize_turn()``). Returns the
|
||||
(possibly modified) list and syncs the session DB via ``archive_and_compact`` (the
|
||||
append-only flush alone would double-load on resume)."""
|
||||
if not self._micro_compact_enabled:
|
||||
return messages
|
||||
|
||||
@@ -229,9 +208,7 @@ class MicroCompactionMixin:
|
||||
self._micro_compact_turns_since_pass = 0
|
||||
|
||||
n_messages = len(messages)
|
||||
if n_messages < 4:
|
||||
return messages
|
||||
exchange = self._next_exchange(messages)
|
||||
exchange = self._next_exchange(messages) if n_messages >= 4 else None
|
||||
if exchange is None:
|
||||
return messages
|
||||
exchange_start, exchange_end = exchange
|
||||
@@ -253,10 +230,8 @@ class MicroCompactionMixin:
|
||||
if defragged:
|
||||
self._sync_micro_compact_to_db(messages)
|
||||
self._reset_micro_failure_tracking()
|
||||
_telemetry(
|
||||
"defrag" if defragged else "defrag_failed", messages,
|
||||
tokens_after=estimate_messages_tokens_rough(messages),
|
||||
)
|
||||
outcome = "defrag" if defragged else "defrag_failed"
|
||||
_telemetry(outcome, messages, tokens_after=estimate_messages_tokens_rough(messages))
|
||||
return messages
|
||||
|
||||
# Cumulative iff it subsumes an earlier marker; captured before summarizing.
|
||||
@@ -278,24 +253,20 @@ class MicroCompactionMixin:
|
||||
self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1)
|
||||
self._sync_micro_compact_to_db(result)
|
||||
_telemetry(
|
||||
"absorbed", result,
|
||||
tokens_after=estimate_messages_tokens_rough(result), exchange_tokens=_exchange_tokens,
|
||||
"absorbed", result, tokens_after=estimate_messages_tokens_rough(result), exchange_tokens=_exchange_tokens,
|
||||
)
|
||||
return result
|
||||
|
||||
def _record_micro_failure(self, exchange_start: int, exchange_end: int) -> str:
|
||||
"""Count a summarize failure at this cursor; skip the exchange after too many in a row."""
|
||||
# Track consecutive failures at the same cursor to avoid busy-looping every turn.
|
||||
if exchange_start == self._micro_compact_last_failure_cursor:
|
||||
self._micro_compact_consecutive_failures += 1
|
||||
else:
|
||||
self._micro_compact_consecutive_failures = 1
|
||||
self._micro_compact_last_failure_cursor = exchange_start
|
||||
same_cursor = exchange_start == self._micro_compact_last_failure_cursor
|
||||
self._micro_compact_consecutive_failures = self._micro_compact_consecutive_failures + 1 if same_cursor else 1
|
||||
self._micro_compact_last_failure_cursor = exchange_start
|
||||
if self._micro_compact_consecutive_failures < _cc()._MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES:
|
||||
return "summarize_failed"
|
||||
logger.info(
|
||||
"Micro-compaction: skipping exchange at cursor %d "
|
||||
"after %d consecutive failures",
|
||||
"Micro-compaction: skipping exchange at cursor %d after %d consecutive failures",
|
||||
exchange_start, self._micro_compact_consecutive_failures,
|
||||
)
|
||||
# Skip the stuck exchange; it stays in the transcript for batch compression/defrag.
|
||||
@@ -310,9 +281,7 @@ class MicroCompactionMixin:
|
||||
if compress_start >= compress_end:
|
||||
return None
|
||||
cursor = self._resolve_compact_cursor(messages, compress_start, compress_end)
|
||||
if cursor >= compress_end:
|
||||
return None
|
||||
return self._find_one_exchange(messages, cursor, compress_end)
|
||||
return None if cursor >= compress_end else self._find_one_exchange(messages, cursor, compress_end)
|
||||
|
||||
@staticmethod
|
||||
def _rolling_summary_from_marker(content: Any) -> str:
|
||||
@@ -320,70 +289,44 @@ class MicroCompactionMixin:
|
||||
cc = _cc()
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
return ""
|
||||
body = content
|
||||
# rfind: SUMMARY_PREFIX itself mentions the heading, so the first hit is in the preamble.
|
||||
idx = body.rfind(cc.HISTORICAL_TASK_HEADING)
|
||||
if idx != -1:
|
||||
body = body[idx + len(cc.HISTORICAL_TASK_HEADING):]
|
||||
idx = content.rfind(cc.HISTORICAL_TASK_HEADING)
|
||||
body = content[idx + len(cc.HISTORICAL_TASK_HEADING):] if idx != -1 else content
|
||||
end = body.find(cc._SUMMARY_END_MARKER)
|
||||
if end != -1:
|
||||
body = body[:end]
|
||||
return body.strip()
|
||||
return (body[:end] if end != -1 else body).strip()
|
||||
|
||||
def _cursor_after_splice(self, result: List[Dict[str, Any]], fallback: int) -> int:
|
||||
"""Cursor position just past the summary marker in *result*.
|
||||
|
||||
Must derive from the SPLICED list: a splice collapses several rows into one marker
|
||||
(and may drop a superseded one), so pre-splice indices land inside a later exchange
|
||||
and silently skip it.
|
||||
"""
|
||||
for idx in range(len(result) - 1, -1, -1):
|
||||
if _is_summary_marker(result[idx]):
|
||||
return idx + 1
|
||||
return fallback
|
||||
"""Cursor position just past the summary marker in *result*. Must derive from the SPLICED
|
||||
list: a splice collapses several rows into one marker (and may drop a superseded one), so
|
||||
pre-splice indices land inside a later exchange and silently skip it."""
|
||||
return next((idx + 1 for idx in range(len(result) - 1, -1, -1) if _is_summary_marker(result[idx])), fallback)
|
||||
|
||||
def _emit_micro_compaction_telemetry(
|
||||
self, *, outcome: str, messages_before: int, messages_after: int,
|
||||
tokens_before: int | None, tokens_after: int | None,
|
||||
exchange_tokens: int | None = None, duration_ms: int | None = None,
|
||||
self, *, outcome: str, messages_before: int, messages_after: int, tokens_before: int | None,
|
||||
tokens_after: int | None, exchange_tokens: int | None = None, duration_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Emit one content-free JSON log line for a micro-compaction pass.
|
||||
|
||||
``tokens_delta`` < 0 means the pass shrank the transcript; ``*_total`` fields accumulate.
|
||||
"""
|
||||
``tokens_delta`` < 0 means the pass shrank the transcript; ``*_total`` fields accumulate."""
|
||||
_safe_int = _cc()._safe_int
|
||||
try:
|
||||
delta = None
|
||||
if tokens_before is not None and tokens_after is not None:
|
||||
delta = tokens_after - tokens_before
|
||||
self._micro_compact_tokens_saved_total -= delta
|
||||
delta = tokens_after - tokens_before if tokens_before is not None and tokens_after is not None else None
|
||||
self._micro_compact_tokens_saved_total -= delta or 0
|
||||
self._micro_compact_passes += 1
|
||||
# Cached reads only: the lazy properties can fire a synchronous /models probe.
|
||||
threshold = self._threshold_tokens
|
||||
occupancy = None
|
||||
if threshold and tokens_after is not None and threshold > 0:
|
||||
occupancy = round(tokens_after / threshold * 100, 1)
|
||||
has_occupancy = threshold and tokens_after is not None and threshold > 0
|
||||
occupancy = round(tokens_after / threshold * 100, 1) if has_occupancy else None
|
||||
payload = {
|
||||
"event": "micro_compaction",
|
||||
"session_id": getattr(self, "_session_id", "") or "",
|
||||
"outcome": outcome,
|
||||
"messages_before": messages_before,
|
||||
"messages_after": messages_after,
|
||||
"tokens_before": _safe_int(tokens_before),
|
||||
"tokens_after": _safe_int(tokens_after),
|
||||
"tokens_delta": _safe_int(delta),
|
||||
"exchange_tokens": _safe_int(exchange_tokens),
|
||||
"event": "micro_compaction", "session_id": getattr(self, "_session_id", "") or "", "outcome": outcome,
|
||||
"messages_before": messages_before, "messages_after": messages_after,
|
||||
"tokens_before": _safe_int(tokens_before), "tokens_after": _safe_int(tokens_after),
|
||||
"tokens_delta": _safe_int(delta), "exchange_tokens": _safe_int(exchange_tokens),
|
||||
"rolling_summary_tokens": estimate_tokens_rough(self._micro_compact_rolling_summary),
|
||||
"cursor": _safe_int(self._micro_compact_cursor),
|
||||
"passes_total": self._micro_compact_passes,
|
||||
"tokens_saved_total": self._micro_compact_tokens_saved_total,
|
||||
"duration_ms": _safe_int(duration_ms),
|
||||
"cursor": _safe_int(self._micro_compact_cursor), "passes_total": self._micro_compact_passes,
|
||||
"tokens_saved_total": self._micro_compact_tokens_saved_total, "duration_ms": _safe_int(duration_ms),
|
||||
# Headroom: how full the window is being kept.
|
||||
"threshold_tokens": _safe_int(threshold),
|
||||
"context_limit": _safe_int(self._resolved_context_length),
|
||||
"occupancy_pct": occupancy,
|
||||
"main_model": self.model or "",
|
||||
"aux_model": self.summary_model or "",
|
||||
"threshold_tokens": _safe_int(threshold), "context_limit": _safe_int(self._resolved_context_length),
|
||||
"occupancy_pct": occupancy, "main_model": self.model or "", "aux_model": self.summary_model or "",
|
||||
}
|
||||
logger.info("micro compaction telemetry: %s", json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||
except Exception as exc:
|
||||
@@ -391,10 +334,8 @@ class MicroCompactionMixin:
|
||||
|
||||
def _sync_micro_compact_to_db(self, compacted_messages: List[Dict[str, Any]]) -> None:
|
||||
"""Persist the micro-compacted set to the session DB atomically and stamp rows persisted.
|
||||
|
||||
Without this the old exchange rows stay ``active=1`` and a resume double-loads
|
||||
both the summary and the originals.
|
||||
"""
|
||||
Without this the old exchange rows stay ``active=1`` and a resume double-loads both the
|
||||
summary and the originals."""
|
||||
session_db, session_id = getattr(self, "_session_db", None), getattr(self, "_session_id", "")
|
||||
if not session_db or not session_id:
|
||||
return
|
||||
@@ -413,17 +354,14 @@ class MicroCompactionMixin:
|
||||
self, messages: List[Dict[str, Any]], splice_start: int, splice_end: int, supersede: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Replace *messages[splice_start:splice_end]* with an assistant-role summary marker.
|
||||
|
||||
Merges user turns left adjacent by a superseded marker so the result is alternation-valid.
|
||||
"""
|
||||
Merges user turns left adjacent by a superseded marker so the result is alternation-valid."""
|
||||
cc = _cc()
|
||||
summary_text = self._micro_compact_rolling_summary
|
||||
if not summary_text.strip():
|
||||
return messages
|
||||
|
||||
summary_msg = {
|
||||
"role": "assistant",
|
||||
"content": self._render_micro_marker_content(summary_text),
|
||||
"role": "assistant", "content": self._render_micro_marker_content(summary_text),
|
||||
cc.COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
# Micro marker: eligible for supersede/defrag; batch markers never carry this key.
|
||||
cc.MICRO_COMPACT_MARKER_KEY: True,
|
||||
@@ -434,10 +372,9 @@ class MicroCompactionMixin:
|
||||
|
||||
# Cumulative summary: keep only the newest marker. Drop an older one only if supersede AND
|
||||
# it has MICRO_COMPACT_MARKER_KEY (provably absorbed); a batch marker holds MORE history.
|
||||
if supersede:
|
||||
marker_idxs = [i for i, m in enumerate(result) if _is_micro_marker(m)]
|
||||
if len(marker_idxs) > 1:
|
||||
result = self._merge_adjacent_user_turns([m for i, m in enumerate(result) if i not in marker_idxs[:-1]])
|
||||
stale = [i for i, m in enumerate(result) if _is_micro_marker(m)][:-1] if supersede else []
|
||||
if stale:
|
||||
result = self._merge_adjacent_user_turns([m for i, m in enumerate(result) if i not in stale])
|
||||
|
||||
# Deliberately no _strip_persistence_markers: micro archives in place under the same session
|
||||
# id, so stamps stay accurate and a failed archive keeps the append-only flush idempotent.
|
||||
@@ -451,17 +388,15 @@ class MicroCompactionMixin:
|
||||
|
||||
@staticmethod
|
||||
def _merge_adjacent_user_turns(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Merge consecutive plain-text real user turns left by a supersede.
|
||||
|
||||
Same ``\\n\\n`` join as ``repair_message_sequence`` pass 2, done here so the marker
|
||||
and cursor are never collateral damage of the downstream repair. Lists untouched.
|
||||
"""
|
||||
"""Merge consecutive plain-text real user turns left by a supersede. Same ``\\n\\n`` join as
|
||||
``repair_message_sequence`` pass 2, done here so the marker and cursor are never collateral
|
||||
damage of the downstream repair. Lists untouched."""
|
||||
from agent.turn_context import drop_stale_api_content
|
||||
|
||||
def _plain_user(m: Any) -> bool:
|
||||
return (
|
||||
isinstance(m, dict) and m.get("role") == "user"
|
||||
and not _is_summary_marker(m) and isinstance(m.get("content"), str)
|
||||
isinstance(m, dict) and m.get("role") == "user" and not _is_summary_marker(m)
|
||||
and isinstance(m.get("content"), str)
|
||||
)
|
||||
|
||||
merged: List[Dict[str, Any]] = []
|
||||
|
||||
+33
-58
@@ -202,10 +202,9 @@ def _slot_label(slot: dict[str, Any]) -> str:
|
||||
|
||||
def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Translate optional per-MoA-slot reasoning_effort into runtime config."""
|
||||
effort = slot.get("reasoning_effort")
|
||||
try:
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
return parse_reasoning_effort(effort)
|
||||
return parse_reasoning_effort(slot.get("reasoning_effort"))
|
||||
except Exception: # pragma: no cover - bad config must not break MoA
|
||||
return None
|
||||
|
||||
@@ -246,11 +245,10 @@ def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
rt = resolve_runtime_provider(requested=provider, target_model=model)
|
||||
out.update({k: rt[k] for k in ("base_url", "api_key", "api_mode") if rt.get(k)})
|
||||
request_overrides = rt.get("request_overrides")
|
||||
if isinstance(request_overrides, dict):
|
||||
extra_body = request_overrides.get("extra_body")
|
||||
if isinstance(extra_body, dict) and extra_body:
|
||||
out["extra_body"] = dict(extra_body)
|
||||
overrides = rt.get("request_overrides")
|
||||
extra_body = overrides.get("extra_body") if isinstance(overrides, dict) else None
|
||||
if isinstance(extra_body, dict) and extra_body:
|
||||
out["extra_body"] = dict(extra_body)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
|
||||
return out
|
||||
@@ -261,27 +259,21 @@ def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def _merge_slot_extra_body(slot_extra_body: Any, caller_extra_body: Any) -> Any:
|
||||
"""Merge slot defaults with a caller override (caller wins) for ``call_llm``."""
|
||||
if isinstance(slot_extra_body, dict) and slot_extra_body:
|
||||
if isinstance(caller_extra_body, dict):
|
||||
return {**slot_extra_body, **caller_extra_body}
|
||||
if caller_extra_body:
|
||||
return caller_extra_body
|
||||
return dict(slot_extra_body)
|
||||
return caller_extra_body
|
||||
if not (isinstance(slot_extra_body, dict) and slot_extra_body):
|
||||
return caller_extra_body
|
||||
if isinstance(caller_extra_body, dict):
|
||||
return {**slot_extra_body, **caller_extra_body}
|
||||
return caller_extra_body or dict(slot_extra_body)
|
||||
|
||||
|
||||
def _agent_cache_opts(agent: Any) -> tuple[Any, Any]:
|
||||
"""The live agent's ``(_cache_disabled, _cache_ttl)``; ``(None, None)`` without an agent."""
|
||||
if agent is None:
|
||||
return None, None
|
||||
return getattr(agent, "_cache_disabled", None), getattr(agent, "_cache_ttl", None)
|
||||
return (None, None) if agent is None else (getattr(agent, "_cache_disabled", None), getattr(agent, "_cache_ttl", None))
|
||||
|
||||
|
||||
def _with_cache_disabled(runtime: dict[str, Any], cache_disabled: Any) -> dict[str, Any]:
|
||||
"""Pin the live agent's cache disable onto a runtime snapshot (None is a no-op)."""
|
||||
if cache_disabled is None:
|
||||
return runtime
|
||||
return {**runtime, "_cache_disabled": cache_disabled}
|
||||
return runtime if cache_disabled is None else {**runtime, "_cache_disabled": cache_disabled}
|
||||
|
||||
|
||||
def _maybe_apply_moa_cache_control(
|
||||
@@ -697,9 +689,7 @@ def _preset_temperature(preset: dict[str, Any], key: str) -> float | None:
|
||||
|
||||
|
||||
def _hash_messages(msgs: list[dict[str, Any]]) -> str:
|
||||
return hashlib.sha256(
|
||||
"\u0000".join(f"{m.get('role')}:{m.get('content')}" for m in msgs).encode("utf-8", "replace")
|
||||
).hexdigest()
|
||||
return hashlib.sha256("\u0000".join(f"{m.get('role')}:{m.get('content')}" for m in msgs).encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def _is_failed_reference(text: str) -> bool:
|
||||
@@ -777,10 +767,8 @@ def aggregate_moa_context(
|
||||
logger.debug("MoA privacy filter check failed", exc_info=True)
|
||||
agg_refs, degraded, all_failed = _guidance_inputs(reference_outputs, privacy_full, degraded_reference_policy)
|
||||
joined = _join_reference_outputs(agg_refs, degraded)
|
||||
|
||||
# Every reference failed: skip the aggregator (synthesizing over nothing can block
|
||||
# for the full provider timeout) and return only the sanitized notice.
|
||||
if all_failed:
|
||||
# Skip the aggregator: synthesizing over nothing can block for the full provider timeout.
|
||||
logger.warning("MoA: all %d reference(s) failed — skipping aggregator synthesis", len(reference_outputs))
|
||||
return (
|
||||
"[Mixture of Agents context — all reference models failed. "
|
||||
@@ -806,14 +794,12 @@ def aggregate_moa_context(
|
||||
# Same cache_control decoration as the advisor calls; this synthesis call is
|
||||
# a third independent MoA call path that otherwise re-bills its full input.
|
||||
agg_messages = _maybe_apply_moa_cache_control(
|
||||
[{"role": "user", "content": synth_prompt}],
|
||||
_with_cache_disabled(agg_runtime, cache_disabled), cache_ttl=cache_ttl,
|
||||
[{"role": "user", "content": synth_prompt}], _with_cache_disabled(agg_runtime, cache_disabled), cache_ttl=cache_ttl,
|
||||
)
|
||||
response = call_llm(
|
||||
synthesis = _extract_text(call_llm(
|
||||
task="moa_aggregator", messages=agg_messages, temperature=aggregator_temperature,
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator), **agg_runtime,
|
||||
)
|
||||
synthesis = _extract_text(response)
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.warning("MoA aggregator model %s failed: %s", agg_label, exc)
|
||||
synthesis = ""
|
||||
@@ -838,9 +824,7 @@ def _completed_response_as_stream_chunk(response: Any) -> Any:
|
||||
if isinstance(raw_tool_calls, (list, tuple)) and raw_tool_calls:
|
||||
tool_call_deltas = [
|
||||
SimpleNamespace(
|
||||
index=getattr(tc, "index", index),
|
||||
id=getattr(tc, "id", None),
|
||||
type=getattr(tc, "type", None) or "function",
|
||||
index=getattr(tc, "index", index), id=getattr(tc, "id", None), type=getattr(tc, "type", None) or "function",
|
||||
function=SimpleNamespace(
|
||||
name=getattr(getattr(tc, "function", None), "name", None),
|
||||
arguments=getattr(getattr(tc, "function", None), "arguments", None),
|
||||
@@ -853,12 +837,10 @@ def _completed_response_as_stream_chunk(response: Any) -> Any:
|
||||
**{k: getattr(message, k, None) for k in ("reasoning_content", "reasoning", "reasoning_details")},
|
||||
)
|
||||
choice = SimpleNamespace(
|
||||
index=getattr(first_choice, "index", 0), delta=delta,
|
||||
finish_reason=getattr(first_choice, "finish_reason", None) or "stop",
|
||||
index=getattr(first_choice, "index", 0), delta=delta, finish_reason=getattr(first_choice, "finish_reason", None) or "stop",
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=getattr(response, "id", None), model=getattr(response, "model", None), choices=[choice],
|
||||
usage=getattr(response, "usage", None),
|
||||
id=getattr(response, "id", None), model=getattr(response, "model", None), choices=[choice], usage=getattr(response, "usage", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -871,15 +853,13 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
|
||||
consecutive user turns would be rejected by strict providers).
|
||||
"""
|
||||
last = agg_messages[-1] if agg_messages else None
|
||||
if last is not None and last.get("role") == "user":
|
||||
last_content = last.get("content")
|
||||
if isinstance(last_content, str):
|
||||
last["content"] = last_content + "\n\n" + guidance
|
||||
return
|
||||
if isinstance(last_content, list):
|
||||
last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}]
|
||||
return
|
||||
agg_messages.append({"role": "user", "content": guidance})
|
||||
last_content = last.get("content") if last is not None and last.get("role") == "user" else None
|
||||
if isinstance(last_content, str):
|
||||
last["content"] = last_content + "\n\n" + guidance
|
||||
elif isinstance(last_content, list):
|
||||
last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}]
|
||||
else:
|
||||
agg_messages.append({"role": "user", "content": guidance})
|
||||
|
||||
|
||||
def peel_reference_guidance(messages: list[dict[str, Any]], guidance: Any) -> list[dict[str, Any]]:
|
||||
@@ -892,26 +872,21 @@ def peel_reference_guidance(messages: list[dict[str, Any]], guidance: Any) -> li
|
||||
if not isinstance(last, dict) or last.get("role") != "user":
|
||||
return messages
|
||||
content = last.get("content")
|
||||
if content == guidance_text:
|
||||
# Attach shape (c): guidance was appended as its own user message.
|
||||
if content == guidance_text: # shape (c): guidance was its own user message
|
||||
return list(messages[:-1])
|
||||
suffix = "\n\n" + guidance_text
|
||||
if isinstance(content, str) and content.endswith(suffix):
|
||||
# Attach shape (a): merged into a trailing string user turn.
|
||||
if isinstance(content, str) and content.endswith(suffix): # shape (a): merged into a string turn
|
||||
return [*messages[:-1], {**last, "content": content[: -len(suffix)]}]
|
||||
if isinstance(content, list) and content:
|
||||
last_part = content[-1]
|
||||
if isinstance(last_part, dict) and last_part.get("type", "text") == "text":
|
||||
text = last_part.get("text") or ""
|
||||
if text in (suffix, guidance_text):
|
||||
# Attach shape (b): guidance rode as its own trailing part. Guidance as
|
||||
# the only content drops the whole message (mirrors shape c).
|
||||
if len(content) == 1:
|
||||
return list(messages[:-1])
|
||||
return [*messages[:-1], {**last, "content": list(content[:-1])}]
|
||||
# Shape (b): guidance rode as its own trailing part. Guidance as the
|
||||
# only content drops the whole message (mirrors shape c).
|
||||
return list(messages[:-1]) if len(content) == 1 else [*messages[:-1], {**last, "content": list(content[:-1])}]
|
||||
if text.endswith(suffix):
|
||||
new_part = {**last_part, "text": text[: -len(suffix)]}
|
||||
return [*messages[:-1], {**last, "content": [*content[:-1], new_part]}]
|
||||
return [*messages[:-1], {**last, "content": [*content[:-1], {**last_part, "text": text[: -len(suffix)]}]}]
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user