Skip to content

Commit f02909e

Browse files
authored
Merge pull request MODSetter#1291 from MODSetter/dev
fix: agent runtime issues
2 parents 4356a24 + 80d3f62 commit f02909e

14 files changed

Lines changed: 578 additions & 241 deletions

File tree

surfsense_backend/app/agents/new_chat/chat_deepagent.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from deepagents.graph import BASE_AGENT_PROMPT
2525
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
2626
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
27-
from deepagents.middleware.summarization import create_summarization_middleware
2827
from langchain.agents import create_agent
2928
from langchain.agents.middleware import TodoListMiddleware
3029
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
@@ -41,6 +40,9 @@
4140
MemoryInjectionMiddleware,
4241
SurfSenseFilesystemMiddleware,
4342
)
43+
from app.agents.new_chat.middleware.safe_summarization import (
44+
create_safe_summarization_middleware,
45+
)
4446
from app.agents.new_chat.system_prompt import (
4547
build_configurable_system_prompt,
4648
build_surfsense_system_prompt,
@@ -442,7 +444,7 @@ async def create_surfsense_deep_agent(
442444
created_by_id=user_id,
443445
thread_id=thread_id,
444446
),
445-
create_summarization_middleware(llm, StateBackend),
447+
create_safe_summarization_middleware(llm, StateBackend),
446448
PatchToolCallsMiddleware(),
447449
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
448450
]
@@ -472,7 +474,7 @@ async def create_surfsense_deep_agent(
472474
thread_id=thread_id,
473475
),
474476
SubAgentMiddleware(backend=StateBackend, subagents=[general_purpose_spec]),
475-
create_summarization_middleware(llm, StateBackend),
477+
create_safe_summarization_middleware(llm, StateBackend),
476478
PatchToolCallsMiddleware(),
477479
DedupHITLToolCallsMiddleware(agent_tools=tools),
478480
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Safe wrapper around deepagents' SummarizationMiddleware.
2+
3+
Upstream issue
4+
--------------
5+
`deepagents.middleware.summarization.SummarizationMiddleware._aoffload_to_backend`
6+
(and its sync counterpart) call
7+
``get_buffer_string(filtered_messages)`` before writing the evicted history
8+
to the backend file. In recent ``langchain-core`` versions, ``get_buffer_string``
9+
accesses ``m.text`` which iterates ``self.content`` — this raises
10+
``TypeError: 'NoneType' object is not iterable`` whenever an ``AIMessage``
11+
has ``content=None`` (common when a model returns *only* tool_calls, seen
12+
frequently with Azure OpenAI ``gpt-5.x`` responses streamed through
13+
LiteLLM).
14+
15+
The exception aborts the whole agent turn, so the user just sees "Error during
16+
chat" with no assistant response.
17+
18+
Fix
19+
---
20+
We subclass ``SummarizationMiddleware`` and override
21+
``_filter_summary_messages`` — the only call site that feeds messages into
22+
``get_buffer_string`` — to return *copies* of messages whose ``content`` is
23+
``None`` with ``content=""``. The originals flowing through the rest of the
24+
agent state are untouched.
25+
26+
We also expose a drop-in ``create_safe_summarization_middleware`` factory
27+
that mirrors ``deepagents.middleware.summarization.create_summarization_middleware``
28+
but instantiates our safe subclass.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import logging
34+
from typing import TYPE_CHECKING
35+
36+
from deepagents.middleware.summarization import (
37+
SummarizationMiddleware,
38+
compute_summarization_defaults,
39+
)
40+
41+
if TYPE_CHECKING:
42+
from deepagents.backends.protocol import BACKEND_TYPES
43+
from langchain_core.language_models import BaseChatModel
44+
from langchain_core.messages import AnyMessage
45+
46+
logger = logging.getLogger(__name__)
47+
48+
49+
def _sanitize_message_content(msg: AnyMessage) -> AnyMessage:
50+
"""Return ``msg`` with ``content`` coerced to a non-``None`` value.
51+
52+
``get_buffer_string`` reads ``m.text`` which iterates ``self.content``;
53+
when a provider streams back an ``AIMessage`` with only tool_calls and
54+
no text, ``content`` can be ``None`` and the iteration explodes. We
55+
replace ``None`` with an empty string so downstream consumers that only
56+
care about text see an empty body.
57+
58+
The original message is left untouched — we return a copy via
59+
pydantic's ``model_copy`` when available, otherwise we fall back to
60+
re-setting the attribute on a shallow copy.
61+
"""
62+
63+
if getattr(msg, "content", "not-missing") is not None:
64+
return msg
65+
66+
try:
67+
return msg.model_copy(update={"content": ""})
68+
except AttributeError:
69+
import copy
70+
71+
new_msg = copy.copy(msg)
72+
try:
73+
new_msg.content = ""
74+
except Exception: # pragma: no cover - defensive
75+
logger.debug(
76+
"Could not sanitize content=None on message of type %s",
77+
type(msg).__name__,
78+
)
79+
return msg
80+
return new_msg
81+
82+
83+
class SafeSummarizationMiddleware(SummarizationMiddleware):
84+
"""`SummarizationMiddleware` that tolerates messages with ``content=None``.
85+
86+
Only ``_filter_summary_messages`` is overridden — this is the single
87+
helper invoked by both the sync and async offload paths immediately
88+
before ``get_buffer_string``. Normalising here means we get coverage
89+
for both without having to copy the (long, rapidly-changing) offload
90+
implementations from upstream.
91+
"""
92+
93+
def _filter_summary_messages(self, messages: list[AnyMessage]) -> list[AnyMessage]:
94+
filtered = super()._filter_summary_messages(messages)
95+
return [_sanitize_message_content(m) for m in filtered]
96+
97+
98+
def create_safe_summarization_middleware(
99+
model: BaseChatModel,
100+
backend: BACKEND_TYPES,
101+
) -> SafeSummarizationMiddleware:
102+
"""Drop-in replacement for ``create_summarization_middleware``.
103+
104+
Mirrors the defaults computed by ``deepagents`` but returns our
105+
``SafeSummarizationMiddleware`` subclass so the
106+
``content=None`` crash in ``get_buffer_string`` is avoided.
107+
"""
108+
109+
defaults = compute_summarization_defaults(model)
110+
return SafeSummarizationMiddleware(
111+
model=model,
112+
backend=backend,
113+
trigger=defaults["trigger"],
114+
keep=defaults["keep"],
115+
trim_tokens_to_summarize=None,
116+
truncate_args_settings=defaults["truncate_args_settings"],
117+
)
118+
119+
120+
__all__ = [
121+
"SafeSummarizationMiddleware",
122+
"create_safe_summarization_middleware",
123+
]

surfsense_backend/app/services/llm_router_service.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,44 @@ def _sanitize_content(content: Any) -> Any:
133133
}
134134

135135

136+
# Default ``api_base`` per LiteLLM provider prefix. Used as a safety net when
137+
# a global LLM config does *not* specify ``api_base``: without this, LiteLLM
138+
# happily picks up provider-agnostic env vars (e.g. ``AZURE_API_BASE``,
139+
# ``OPENAI_API_BASE``) and routes, say, an ``openrouter/anthropic/claude-3-haiku``
140+
# request to an Azure endpoint, which then 404s with ``Resource not found``.
141+
# Only providers with a well-known, stable public base URL are listed here —
142+
# self-hosted / BYO-endpoint providers (ollama, custom, bedrock, vertex_ai,
143+
# huggingface, databricks, cloudflare, replicate) are intentionally omitted
144+
# so their existing config-driven behaviour is preserved.
145+
PROVIDER_DEFAULT_API_BASE = {
146+
"openrouter": "https://openrouter.ai/api/v1",
147+
"groq": "https://api.groq.com/openai/v1",
148+
"mistral": "https://api.mistral.ai/v1",
149+
"perplexity": "https://api.perplexity.ai",
150+
"xai": "https://api.x.ai/v1",
151+
"cerebras": "https://api.cerebras.ai/v1",
152+
"deepinfra": "https://api.deepinfra.com/v1/openai",
153+
"fireworks_ai": "https://api.fireworks.ai/inference/v1",
154+
"together_ai": "https://api.together.xyz/v1",
155+
"anyscale": "https://api.endpoints.anyscale.com/v1",
156+
"cometapi": "https://api.cometapi.com/v1",
157+
"sambanova": "https://api.sambanova.ai/v1",
158+
}
159+
160+
161+
# Canonical provider → base URL when a config uses a generic ``openai``-style
162+
# prefix but the ``provider`` field tells us which API it really is
163+
# (e.g. DeepSeek/Alibaba/Moonshot/Zhipu/MiniMax all use ``openai`` compat but
164+
# each has its own base URL).
165+
PROVIDER_KEY_DEFAULT_API_BASE = {
166+
"DEEPSEEK": "https://api.deepseek.com/v1",
167+
"ALIBABA_QWEN": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
168+
"MOONSHOT": "https://api.moonshot.ai/v1",
169+
"ZHIPU": "https://open.bigmodel.cn/api/paas/v4",
170+
"MINIMAX": "https://api.minimax.io/v1",
171+
}
172+
173+
136174
class LLMRouterService:
137175
"""
138176
Singleton service for managing LiteLLM Router.
@@ -224,6 +262,16 @@ def initialize(
224262
# hits ContextWindowExceededError.
225263
full_model_list, ctx_fallbacks = cls._build_context_fallback_groups(model_list)
226264

265+
# Build a general-purpose fallback list so NotFound/timeout/rate-limit
266+
# style failures on one deployment don't bubble up as hard errors —
267+
# the router retries with a sibling deployment in ``auto-large``.
268+
# ``auto-large`` is the large-context subset of ``auto``; if it is
269+
# empty we fall back to ``auto`` itself so the router at least picks a
270+
# different deployment in the same group.
271+
fallbacks: list[dict[str, list[str]]] | None = None
272+
if ctx_fallbacks:
273+
fallbacks = [{"auto": ["auto-large"]}]
274+
227275
try:
228276
router_kwargs: dict[str, Any] = {
229277
"model_list": full_model_list,
@@ -237,15 +285,18 @@ def initialize(
237285
}
238286
if ctx_fallbacks:
239287
router_kwargs["context_window_fallbacks"] = ctx_fallbacks
288+
if fallbacks:
289+
router_kwargs["fallbacks"] = fallbacks
240290

241291
instance._router = Router(**router_kwargs)
242292
instance._initialized = True
243293
logger.info(
244294
"LLM Router initialized with %d deployments, "
245-
"strategy: %s, context_window_fallbacks: %s",
295+
"strategy: %s, context_window_fallbacks: %s, fallbacks: %s",
246296
len(model_list),
247297
final_settings.get("routing_strategy"),
248298
ctx_fallbacks or "none",
299+
fallbacks or "none",
249300
)
250301
except Exception as e:
251302
logger.error(f"Failed to initialize LLM Router: {e}")
@@ -348,10 +399,11 @@ def _config_to_deployment(cls, config: dict) -> dict | None:
348399
return None
349400

350401
# Build model string
402+
provider = config.get("provider", "").upper()
351403
if config.get("custom_provider"):
352-
model_string = f"{config['custom_provider']}/{config['model_name']}"
404+
provider_prefix = config["custom_provider"]
405+
model_string = f"{provider_prefix}/{config['model_name']}"
353406
else:
354-
provider = config.get("provider", "").upper()
355407
provider_prefix = PROVIDER_MAP.get(provider, provider.lower())
356408
model_string = f"{provider_prefix}/{config['model_name']}"
357409

@@ -361,9 +413,19 @@ def _config_to_deployment(cls, config: dict) -> dict | None:
361413
"api_key": config.get("api_key"),
362414
}
363415

364-
# Add optional api_base
365-
if config.get("api_base"):
366-
litellm_params["api_base"] = config["api_base"]
416+
# Resolve ``api_base``. Config value wins; otherwise apply a
417+
# provider-aware default so the deployment does not silently
418+
# inherit unrelated env vars (e.g. ``AZURE_API_BASE``) and route
419+
# requests to the wrong endpoint. See ``PROVIDER_DEFAULT_API_BASE``
420+
# docstring for the motivating bug (OpenRouter models 404-ing
421+
# against an Azure endpoint).
422+
api_base = config.get("api_base")
423+
if not api_base:
424+
api_base = PROVIDER_KEY_DEFAULT_API_BASE.get(provider)
425+
if not api_base:
426+
api_base = PROVIDER_DEFAULT_API_BASE.get(provider_prefix)
427+
if api_base:
428+
litellm_params["api_base"] = api_base
367429

368430
# Add any additional litellm parameters
369431
if config.get("litellm_params"):

surfsense_backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ dependencies = [
7474
"deepagents>=0.4.12",
7575
"stripe>=15.0.0",
7676
"azure-ai-documentintelligence>=1.0.2",
77-
"litellm>=1.83.0",
77+
"litellm>=1.83.4",
7878
"langchain-litellm>=0.6.4",
7979
]
8080

surfsense_backend/tests/unit/test_error_contract.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,7 @@ def test_503_preserves_detail(self, client):
202202
# Intentional 503s (e.g. feature flag off) must surface the developer
203203
# message so the frontend can render actionable copy.
204204
body = _assert_envelope(client.get("/http-503"), 503)
205-
assert (
206-
body["error"]["message"] == "Page purchases are temporarily unavailable."
207-
)
205+
assert body["error"]["message"] == "Page purchases are temporarily unavailable."
208206
assert body["error"]["message"] != GENERIC_5XX_MESSAGE
209207

210208
def test_502_preserves_detail(self, client):

surfsense_backend/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ export function DesktopContent() {
200200
Launch on Startup
201201
</CardTitle>
202202
<CardDescription className="text-xs md:text-sm">
203-
Automatically start SurfSense when you sign in to your computer so global
204-
shortcuts and folder sync are always available.
203+
Automatically start SurfSense when you sign in to your computer so global shortcuts and
204+
folder sync are always available.
205205
</CardDescription>
206206
</CardHeader>
207207
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
@@ -232,8 +232,7 @@ export function DesktopContent() {
232232
Start minimized to tray
233233
</Label>
234234
<p className="text-xs text-muted-foreground">
235-
Skip the main window on boot — SurfSense lives in the system tray until you need
236-
it.
235+
Skip the main window on boot — SurfSense lives in the system tray until you need it.
237236
</p>
238237
</div>
239238
<Switch

surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,7 @@ export function PurchaseHistoryContent() {
126126
return [
127127
...pagePurchases.map(normalizePagePurchase),
128128
...tokenPurchases.map(normalizeTokenPurchase),
129-
].sort(
130-
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
131-
);
129+
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
132130
}, [pagesQuery.data, tokensQuery.data]);
133131

134132
if (isLoading) {

0 commit comments

Comments
 (0)