mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Relocate the entire new_chat/middleware/ package to the shared kernel as one cohesive unit (it is live shared infrastructure: the multi-agent stack wraps nearly every middleware via multi_agent_chat/middleware/main_agent/*, and anonymous_agent consumes it too). Flip 69 live importers across both the package-path and submodule-path forms. Shims left for the frozen single-agent stack: a package __init__ re-export plus submodule shims for permission, skills_backends, and scoped_model_fallback (the three imported via submodule path by chat_deepagent/subagents). Cycle break: importing shared.middleware previously reached back into new_chat.tools at module load, which dragged in new_chat.__init__ -> chat_deepagent -> the middleware shim -> half-initialized shared.middleware. Made action_log's ToolDefinition import TYPE_CHECKING-only and tool_call_repair's INVALID_TOOL_NAME import function-local. These tools-package back-edges fully resolve in slice 6. Asset note: skills_backends._default_builtin_root now walks to app/agents/new_chat/skills/builtin (the skills/ tree migrates in slice 7).
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""
|
|
``_noop`` provider-compatibility tool + injection middleware.
|
|
|
|
Some providers (LiteLLM, Bedrock, Copilot) 400 when a model call has
|
|
empty ``tools`` but the message history includes prior ``tool_calls`` —
|
|
they treat that shape as malformed even though it's perfectly valid
|
|
LangChain. SurfSense hits this on the compaction summarize call (no
|
|
tools, history full of tool calls).
|
|
|
|
Ported from OpenCode's ``packages/opencode/src/session/llm.ts:209-228``,
|
|
which discovered and codified the workaround: inject a no-op tool *only*
|
|
on those provider shapes so the request validates without ever being
|
|
called.
|
|
|
|
Operation: a :class:`NoopInjectionMiddleware` ``wrap_model_call`` checks
|
|
if the request has zero tools but the last AI message in history includes
|
|
``tool_calls``. If yes, it injects the ``_noop`` tool only — never
|
|
globally — mirroring OpenCode's gating exactly. The :func:`noop_tool`
|
|
returns empty content when called (which it should never be in
|
|
practice).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
from langchain.agents.middleware.types import (
|
|
AgentMiddleware,
|
|
AgentState,
|
|
ContextT,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
ResponseT,
|
|
)
|
|
from langchain_core.messages import AIMessage
|
|
from langchain_core.tools import tool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
NOOP_TOOL_NAME = "_noop"
|
|
NOOP_TOOL_DESCRIPTION = "Do not call this tool. It exists only for API compatibility."
|
|
|
|
|
|
@tool(name_or_callable=NOOP_TOOL_NAME, description=NOOP_TOOL_DESCRIPTION)
|
|
def noop_tool() -> str:
|
|
"""Return empty content. Never expected to be called."""
|
|
return ""
|
|
|
|
|
|
# Provider markers that benefit from ``_noop`` injection. These match
|
|
# OpenCode's gating list (``llm.ts:209-228``). We also accept any string
|
|
# containing one of these substrings so e.g. ``litellm`` matches
|
|
# ``ChatLiteLLM``.
|
|
_NOOP_NEEDED_PROVIDERS: tuple[str, ...] = (
|
|
"litellm",
|
|
"bedrock",
|
|
"copilot",
|
|
)
|
|
|
|
|
|
def _provider_needs_noop(model: Any) -> bool:
|
|
"""Heuristic: does this model's provider need the _noop injection?"""
|
|
try:
|
|
ls_params = model._get_ls_params()
|
|
provider = str(ls_params.get("ls_provider", "")).lower()
|
|
except Exception:
|
|
provider = ""
|
|
|
|
if not provider:
|
|
cls_name = type(model).__name__.lower()
|
|
provider = cls_name
|
|
|
|
return any(needle in provider for needle in _NOOP_NEEDED_PROVIDERS)
|
|
|
|
|
|
def _last_ai_has_tool_calls(messages: list[Any]) -> bool:
|
|
for msg in reversed(messages):
|
|
if isinstance(msg, AIMessage):
|
|
return bool(msg.tool_calls)
|
|
return False
|
|
|
|
|
|
class NoopInjectionMiddleware(
|
|
AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]
|
|
):
|
|
"""Inject the ``_noop`` tool only when the provider would otherwise 400.
|
|
|
|
The check fires per model call, not at agent build time, because the
|
|
summarization path generates a no-tool subcall at runtime. The
|
|
extra tool is appended to ``request.tools`` as an instance — the
|
|
actual ``langchain_core.tools.BaseTool`` is bound on every call site
|
|
that creates the agent.
|
|
"""
|
|
|
|
def __init__(self, *, noop_tool_instance: Any | None = None) -> None:
|
|
super().__init__()
|
|
self._noop_tool = noop_tool_instance or noop_tool
|
|
self.tools = []
|
|
|
|
def _should_inject(self, request: ModelRequest[ContextT]) -> bool:
|
|
if request.tools:
|
|
return False
|
|
if not _last_ai_has_tool_calls(request.messages):
|
|
return False
|
|
return _provider_needs_noop(request.model)
|
|
|
|
def _augmented(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
|
|
return request.override(tools=[self._noop_tool])
|
|
|
|
def wrap_model_call( # type: ignore[override]
|
|
self,
|
|
request: ModelRequest[ContextT],
|
|
handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]],
|
|
) -> Any:
|
|
if self._should_inject(request):
|
|
logger.debug("Injecting _noop tool for provider compatibility")
|
|
return handler(self._augmented(request))
|
|
return handler(request)
|
|
|
|
async def awrap_model_call( # type: ignore[override]
|
|
self,
|
|
request: ModelRequest[ContextT],
|
|
handler: Callable[
|
|
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
|
|
],
|
|
) -> Any:
|
|
if self._should_inject(request):
|
|
logger.debug("Injecting _noop tool for provider compatibility")
|
|
return await handler(self._augmented(request))
|
|
return await handler(request)
|
|
|
|
|
|
__all__ = [
|
|
"NOOP_TOOL_DESCRIPTION",
|
|
"NOOP_TOOL_NAME",
|
|
"NoopInjectionMiddleware",
|
|
"_provider_needs_noop",
|
|
"noop_tool",
|
|
]
|