Merge remote-tracking branch 'origin/main' into nanobot-webui
This commit is contained in:
@@ -648,7 +648,10 @@ class AgentLoop:
|
||||
|
||||
session, pending = self.auto_compact.prepare_session(session, key)
|
||||
|
||||
await self.consolidator.maybe_consolidate_by_tokens(session)
|
||||
await self.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
session_summary=pending,
|
||||
)
|
||||
# Persist subagent follow-ups into durable history BEFORE prompt
|
||||
# assembly. ContextBuilder merges adjacent same-role messages for
|
||||
# provider compatibility, which previously caused the follow-up to
|
||||
@@ -709,7 +712,10 @@ class AgentLoop:
|
||||
if result := await self.commands.dispatch(ctx):
|
||||
return result
|
||||
|
||||
await self.consolidator.maybe_consolidate_by_tokens(session)
|
||||
await self.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
session_summary=pending,
|
||||
)
|
||||
|
||||
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
|
||||
if message_tool := self.tools.get("message"):
|
||||
|
||||
+42
-11
@@ -416,7 +416,12 @@ class Consolidator:
|
||||
return idx
|
||||
return None
|
||||
|
||||
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
|
||||
def estimate_session_prompt_tokens(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
session_summary: str | None = None,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate current prompt size for the normal session history view."""
|
||||
history = session.get_history(max_messages=0)
|
||||
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
|
||||
@@ -425,6 +430,7 @@ class Consolidator:
|
||||
current_message="[token-probe]",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
session_summary=session_summary,
|
||||
)
|
||||
return estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
@@ -467,7 +473,12 @@ class Consolidator:
|
||||
self.store.raw_archive(messages)
|
||||
return None
|
||||
|
||||
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
|
||||
async def maybe_consolidate_by_tokens(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
session_summary: str | None = None,
|
||||
) -> None:
|
||||
"""Loop: archive old messages until prompt fits within safe budget.
|
||||
|
||||
The budget reserves space for completion tokens and a safety buffer
|
||||
@@ -481,7 +492,10 @@ class Consolidator:
|
||||
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||
target = budget // 2
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(session)
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
session_summary=session_summary,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token estimation failed for {}", session.key)
|
||||
estimated, source = 0, "error"
|
||||
@@ -499,9 +513,10 @@ class Consolidator:
|
||||
)
|
||||
return
|
||||
|
||||
last_summary = None
|
||||
for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
|
||||
if estimated <= target:
|
||||
return
|
||||
break
|
||||
|
||||
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
|
||||
if boundary is None:
|
||||
@@ -510,7 +525,7 @@ class Consolidator:
|
||||
session.key,
|
||||
round_num,
|
||||
)
|
||||
return
|
||||
break
|
||||
|
||||
end_idx = boundary[0]
|
||||
end_idx = self._cap_consolidation_boundary(session, end_idx)
|
||||
@@ -520,11 +535,11 @@ class Consolidator:
|
||||
session.key,
|
||||
round_num,
|
||||
)
|
||||
return
|
||||
break
|
||||
|
||||
chunk = session.messages[session.last_consolidated:end_idx]
|
||||
if not chunk:
|
||||
return
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
|
||||
@@ -535,18 +550,34 @@ class Consolidator:
|
||||
source,
|
||||
len(chunk),
|
||||
)
|
||||
if not await self.archive(chunk):
|
||||
return
|
||||
summary = await self.archive(chunk)
|
||||
if summary:
|
||||
last_summary = summary
|
||||
else:
|
||||
break
|
||||
session.last_consolidated = end_idx
|
||||
self.sessions.save(session)
|
||||
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(session)
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
session_summary=session_summary,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token estimation failed for {}", session.key)
|
||||
estimated, source = 0, "error"
|
||||
if estimated <= 0:
|
||||
return
|
||||
break
|
||||
|
||||
# Persist the last summary to session metadata so it can be injected
|
||||
# into the runtime context on the next prepare_session() call, aligning
|
||||
# the summary injection strategy with AutoCompact._archive().
|
||||
if last_summary and last_summary != "(nothing)":
|
||||
session.metadata["_last_summary"] = {
|
||||
"text": last_summary,
|
||||
"last_active": session.updated_at.isoformat(),
|
||||
}
|
||||
self.sessions.save(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,11 +9,13 @@ import importlib.util
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
|
||||
from langfuse.openai import AsyncOpenAI
|
||||
@@ -143,6 +145,10 @@ def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | No
|
||||
return bool(api_base and "openrouter" in api_base.lower())
|
||||
|
||||
|
||||
_RESPONSES_FAILURE_THRESHOLD = 3
|
||||
_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
|
||||
|
||||
|
||||
def _is_direct_openai_base(api_base: str | None) -> bool:
|
||||
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
|
||||
if not api_base:
|
||||
@@ -151,6 +157,16 @@ def _is_direct_openai_base(api_base: str | None) -> bool:
|
||||
return "api.openai.com" in normalized and "openrouter" not in normalized
|
||||
|
||||
|
||||
def _responses_circuit_key(
|
||||
model: str | None,
|
||||
default_model: str,
|
||||
reasoning_effort: str | None,
|
||||
) -> str:
|
||||
model_name = (model or default_model).lower()
|
||||
effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else ""
|
||||
return f"{model_name}:{effort}"
|
||||
|
||||
|
||||
class OpenAICompatProvider(LLMProvider):
|
||||
"""Unified provider for all OpenAI-compatible APIs.
|
||||
|
||||
@@ -189,6 +205,11 @@ class OpenAICompatProvider(LLMProvider):
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
# Responses API circuit breaker: skip after repeated failures,
|
||||
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
|
||||
self._responses_failures: dict[str, int] = {}
|
||||
self._responses_tripped_at: dict[str, float] = {}
|
||||
|
||||
def _setup_env(self, api_key: str, api_base: str | None) -> None:
|
||||
"""Set environment variables based on provider spec."""
|
||||
spec = self._spec
|
||||
@@ -414,9 +435,39 @@ class OpenAICompatProvider(LLMProvider):
|
||||
return False
|
||||
|
||||
model_name = (model or self.default_model).lower()
|
||||
wants = False
|
||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||
return True
|
||||
return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
|
||||
wants = True
|
||||
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
|
||||
wants = True
|
||||
if not wants:
|
||||
return False
|
||||
|
||||
# Circuit breaker: skip after repeated failures, probe periodically.
|
||||
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
||||
failures = self._responses_failures.get(key, 0)
|
||||
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
||||
tripped = self._responses_tripped_at.get(key, 0.0)
|
||||
if (time.monotonic() - tripped) < _RESPONSES_PROBE_INTERVAL_S:
|
||||
return False
|
||||
# Half-open: allow one probe attempt
|
||||
return True
|
||||
|
||||
def _record_responses_failure(self, model: str | None, reasoning_effort: str | None) -> None:
|
||||
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
||||
count = self._responses_failures.get(key, 0) + 1
|
||||
self._responses_failures[key] = count
|
||||
if count >= _RESPONSES_FAILURE_THRESHOLD:
|
||||
self._responses_tripped_at[key] = time.monotonic()
|
||||
logger.warning(
|
||||
"Responses API circuit open for {} — falling back to Chat Completions",
|
||||
key,
|
||||
)
|
||||
|
||||
def _record_responses_success(self, model: str | None, reasoning_effort: str | None) -> None:
|
||||
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
||||
self._responses_failures.pop(key, None)
|
||||
self._responses_tripped_at.pop(key, None)
|
||||
|
||||
@staticmethod
|
||||
def _should_fallback_from_responses_error(e: Exception) -> bool:
|
||||
@@ -915,10 +966,13 @@ class OpenAICompatProvider(LLMProvider):
|
||||
messages, tools, model, max_tokens, temperature,
|
||||
reasoning_effort, tool_choice,
|
||||
)
|
||||
return parse_response_output(await self._client.responses.create(**body))
|
||||
result = parse_response_output(await self._client.responses.create(**body))
|
||||
self._record_responses_success(model, reasoning_effort)
|
||||
return result
|
||||
except Exception as responses_error:
|
||||
if not self._should_fallback_from_responses_error(responses_error):
|
||||
raise
|
||||
self._record_responses_failure(model, reasoning_effort)
|
||||
|
||||
kwargs = self._build_kwargs(
|
||||
messages, tools, model, max_tokens, temperature,
|
||||
@@ -965,6 +1019,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
_timed_stream(),
|
||||
on_content_delta,
|
||||
)
|
||||
self._record_responses_success(model, reasoning_effort)
|
||||
return LLMResponse(
|
||||
content=content or None,
|
||||
tool_calls=tool_calls,
|
||||
@@ -975,6 +1030,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
except Exception as responses_error:
|
||||
if not self._should_fallback_from_responses_error(responses_error):
|
||||
raise
|
||||
self._record_responses_failure(model, reasoning_effort)
|
||||
|
||||
kwargs = self._build_kwargs(
|
||||
messages, tools, model, max_tokens, temperature,
|
||||
|
||||
Reference in New Issue
Block a user