mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
refactor(agents): move middleware package to app/agents/shared (slice 5c)
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).
This commit is contained in:
parent
6f488d9564
commit
227983a104
98 changed files with 1131 additions and 999 deletions
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import ActionLogMiddleware
|
||||
from app.agents.shared.middleware import ActionLogMiddleware
|
||||
from app.agents.new_chat.tools.registry import BUILTIN_TOOLS
|
||||
|
||||
from ..shared.flags import enabled
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.middleware import AnonymousDocumentMiddleware
|
||||
from app.agents.shared.middleware import AnonymousDocumentMiddleware
|
||||
|
||||
|
||||
def build_anonymous_doc_mw(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import BusyMutexMiddleware
|
||||
from app.agents.shared.middleware import BusyMutexMiddleware
|
||||
|
||||
from ..shared.flags import enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.agents.multi_agent_chat.main_agent.context_prune.prune_tool_names impor
|
|||
safe_exclude_tools,
|
||||
)
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import (
|
||||
from app.agents.shared.middleware import (
|
||||
ClearToolUsesEdit,
|
||||
SpillingContextEditingMiddleware,
|
||||
SpillToBackendEdit,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from collections.abc import Sequence
|
|||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware
|
||||
from app.agents.shared.middleware import DedupHITLToolCallsMiddleware
|
||||
|
||||
|
||||
def build_dedup_hitl_mw(tools: Sequence[BaseTool]) -> DedupHITLToolCallsMiddleware:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import DoomLoopMiddleware
|
||||
from app.agents.shared.middleware import DoomLoopMiddleware
|
||||
|
||||
from ..shared.flags import enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.middleware import KnowledgeBasePersistenceMiddleware
|
||||
from app.agents.shared.middleware import KnowledgeBasePersistenceMiddleware
|
||||
|
||||
|
||||
def build_kb_persistence_mw(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.middleware import KnowledgePriorityMiddleware
|
||||
from app.agents.shared.middleware import KnowledgePriorityMiddleware
|
||||
from app.services.llm_service import get_planner_llm
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.middleware import KnowledgeTreeMiddleware
|
||||
from app.agents.shared.middleware import KnowledgeTreeMiddleware
|
||||
|
||||
|
||||
def build_knowledge_tree_mw(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import NoopInjectionMiddleware
|
||||
from app.agents.shared.middleware import NoopInjectionMiddleware
|
||||
|
||||
from ..shared.flags import enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import OtelSpanMiddleware
|
||||
from app.agents.shared.middleware import OtelSpanMiddleware
|
||||
|
||||
from ..shared.flags import enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from collections.abc import Sequence
|
|||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import ToolCallNameRepairMiddleware
|
||||
from app.agents.shared.middleware import ToolCallNameRepairMiddleware
|
||||
|
||||
from ..shared.flags import enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from deepagents.middleware.skills import SkillsMiddleware
|
|||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.middleware import (
|
||||
from app.agents.shared.middleware import (
|
||||
build_skills_backend_factory,
|
||||
default_skills_sources,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from typing import Any
|
|||
from deepagents.backends import StateBackend
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from app.agents.new_chat.middleware import create_surfsense_compaction_middleware
|
||||
from app.agents.shared.middleware import create_surfsense_compaction_middleware
|
||||
|
||||
|
||||
def build_compaction_mw(llm: BaseChatModel) -> Any:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from langchain.tools import ToolRuntime
|
|||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
|
||||
from app.agents.shared.middleware.multi_root_local_folder_backend import (
|
||||
MultiRootLocalFolderBackend,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from langchain_core.tools import BaseTool, StructuredTool
|
|||
from langgraph.types import Command
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.mode import is_cloud
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from langchain.tools import ToolRuntime
|
|||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.path_resolution import resolve_list_target_path
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from langchain.tools import ToolRuntime
|
|||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import paginate_listing
|
||||
from app.agents.shared.middleware.kb_postgres_backend import paginate_listing
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.path_resolution import resolve_list_target_path
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from langchain_core.messages import ToolMessage
|
|||
from langgraph.types import Command
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
from app.agents.shared.state_reducers import _CLEAR
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from langchain_core.tools import BaseTool, StructuredTool
|
|||
from langgraph.types import Command
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.path_resolution import resolve_relative
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from langchain_core.messages import ToolMessage
|
|||
from langgraph.types import Command
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
from app.agents.shared.state_reducers import _CLEAR
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from langchain_core.messages import ToolMessage
|
|||
from langgraph.types import Command
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
from app.agents.shared.state_reducers import _CLEAR
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from langchain_core.messages import SystemMessage
|
|||
from langgraph.runtime import Runtime
|
||||
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.knowledge_search import _render_priority_message
|
||||
from app.agents.shared.middleware.knowledge_search import _render_priority_message
|
||||
from app.utils.perf import get_perf_logger
|
||||
|
||||
_perf_log = get_perf_logger()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.agents.new_chat.middleware import MemoryInjectionMiddleware
|
||||
from app.agents.shared.middleware import MemoryInjectionMiddleware
|
||||
from app.db import ChatVisibility
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ from langchain.agents.middleware import (
|
|||
)
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import RetryAfterMiddleware
|
||||
from app.agents.new_chat.middleware.scoped_model_fallback import (
|
||||
from app.agents.shared.middleware import RetryAfterMiddleware
|
||||
from app.agents.shared.middleware.scoped_model_fallback import (
|
||||
ScopedModelFallbackMiddleware,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware.scoped_model_fallback import (
|
||||
from app.agents.shared.middleware.scoped_model_fallback import (
|
||||
ScopedModelFallbackMiddleware,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.middleware import RetryAfterMiddleware
|
||||
from app.agents.shared.middleware import RetryAfterMiddleware
|
||||
|
||||
from ..flags import enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from langchain_core.language_models import BaseChatModel
|
|||
from langgraph.types import Checkpointer
|
||||
|
||||
from app.agents.new_chat.context import SurfSenseContextSchema
|
||||
from app.agents.new_chat.middleware import (
|
||||
from app.agents.shared.middleware import (
|
||||
RetryAfterMiddleware,
|
||||
create_surfsense_compaction_middleware,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ from deepagents.backends.state import StateBackend
|
|||
from langgraph.prebuilt.tool_node import ToolRuntime
|
||||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.middleware.multi_root_local_folder_backend import (
|
||||
MultiRootLocalFolderBackend,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,40 @@
|
|||
"""Middleware components for the SurfSense new chat agent."""
|
||||
"""Backward-compatible shim package.
|
||||
|
||||
from app.agents.new_chat.middleware.action_log import ActionLogMiddleware
|
||||
from app.agents.new_chat.middleware.anonymous_document import (
|
||||
The agent middleware now lives in the shared kernel at
|
||||
``app.agents.shared.middleware``. This package re-exports it so frozen
|
||||
single-agent code (``chat_deepagent`` and ``subagents/*``) keeps working
|
||||
until that stack is retired.
|
||||
"""
|
||||
|
||||
from app.agents.shared.middleware import (
|
||||
ActionLogMiddleware,
|
||||
AnonymousDocumentMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.busy_mutex import BusyMutexMiddleware
|
||||
from app.agents.new_chat.middleware.compaction import (
|
||||
SurfSenseCompactionMiddleware,
|
||||
create_surfsense_compaction_middleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.context_editing import (
|
||||
BuiltinSkillsBackend,
|
||||
BusyMutexMiddleware,
|
||||
ClearToolUsesEdit,
|
||||
SpillingContextEditingMiddleware,
|
||||
SpillToBackendEdit,
|
||||
)
|
||||
from app.agents.new_chat.middleware.dedup_tool_calls import (
|
||||
DedupHITLToolCallsMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.doom_loop import DoomLoopMiddleware
|
||||
from app.agents.new_chat.middleware.file_intent import (
|
||||
DoomLoopMiddleware,
|
||||
FileIntentMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.filesystem import (
|
||||
SurfSenseFilesystemMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.flatten_system import (
|
||||
FlattenSystemMessageMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.kb_persistence import (
|
||||
KnowledgeBasePersistenceMiddleware,
|
||||
commit_staged_filesystem_state,
|
||||
)
|
||||
from app.agents.new_chat.middleware.knowledge_search import (
|
||||
KnowledgeBaseSearchMiddleware,
|
||||
KnowledgePriorityMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.knowledge_tree import (
|
||||
KnowledgeTreeMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.memory_injection import (
|
||||
MemoryInjectionMiddleware,
|
||||
)
|
||||
from app.agents.new_chat.middleware.noop_injection import NoopInjectionMiddleware
|
||||
from app.agents.new_chat.middleware.otel_span import OtelSpanMiddleware
|
||||
from app.agents.new_chat.middleware.permission import PermissionMiddleware
|
||||
from app.agents.new_chat.middleware.retry_after import RetryAfterMiddleware
|
||||
from app.agents.new_chat.middleware.skills_backends import (
|
||||
BuiltinSkillsBackend,
|
||||
NoopInjectionMiddleware,
|
||||
OtelSpanMiddleware,
|
||||
PermissionMiddleware,
|
||||
RetryAfterMiddleware,
|
||||
SearchSpaceSkillsBackend,
|
||||
build_skills_backend_factory,
|
||||
default_skills_sources,
|
||||
)
|
||||
from app.agents.new_chat.middleware.tool_call_repair import (
|
||||
SpillingContextEditingMiddleware,
|
||||
SpillToBackendEdit,
|
||||
SurfSenseCompactionMiddleware,
|
||||
SurfSenseFilesystemMiddleware,
|
||||
ToolCallNameRepairMiddleware,
|
||||
build_skills_backend_factory,
|
||||
commit_staged_filesystem_state,
|
||||
create_surfsense_compaction_middleware,
|
||||
default_skills_sources,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -1,424 +1,14 @@
|
|||
"""
|
||||
PermissionMiddleware — pattern-based allow/deny/ask with HITL fallback.
|
||||
"""Backward-compatible shim.
|
||||
|
||||
LangChain's :class:`HumanInTheLoopMiddleware` only supports a static
|
||||
"this tool always asks" decision per tool. There's no rule-based
|
||||
allow/deny/ask layered ruleset, no glob patterns, no per-search-space or
|
||||
per-thread overrides, and no auto-deny synthesis.
|
||||
|
||||
This middleware ports OpenCode's ``packages/opencode/src/permission/index.ts``
|
||||
ruleset model on top of SurfSense's existing ``interrupt({type, action,
|
||||
context})`` payload shape (see ``app/agents/new_chat/tools/hitl.py``) so
|
||||
the frontend keeps working unchanged.
|
||||
|
||||
Operation:
|
||||
1. ``aafter_model`` inspects the latest ``AIMessage.tool_calls``.
|
||||
2. For each call, the middleware builds a list of ``patterns`` (the
|
||||
tool name plus any tool-specific patterns from the resolver). It
|
||||
evaluates each pattern against the layered rulesets and aggregates
|
||||
the results: ``deny`` > ``ask`` > ``allow``.
|
||||
3. On ``deny``: replaces the call with a synthetic ``ToolMessage``
|
||||
containing a :class:`StreamingError`.
|
||||
4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. Both the legacy
|
||||
SurfSense shape and LangChain HITL ``{"decisions": [{"type": ...}]}``
|
||||
replies are accepted via :func:`_normalize_permission_decision`.
|
||||
- ``once``: proceed.
|
||||
- ``approve_always``: also persist allow rules for ``request.always`` patterns.
|
||||
- ``reject`` w/o feedback: raise :class:`RejectedError`.
|
||||
- ``reject`` w/ feedback: raise :class:`CorrectedError`.
|
||||
5. On ``allow``: proceed unchanged.
|
||||
|
||||
The middleware also performs a *pre-model* tool-filter step (the
|
||||
``before_model`` hook) so globally denied tools are stripped from the
|
||||
exposed tool list before the model gets to see them. This mirrors
|
||||
OpenCode's ``Permission.disabled`` and dramatically reduces the chance
|
||||
the model emits a deny-only call.
|
||||
Moved to ``app.agents.shared.middleware.permission``. Re-exported here for the
|
||||
frozen single-agent stack (``chat_deepagent``/``subagents``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ContextT,
|
||||
from app.agents.shared.middleware.permission import (
|
||||
PatternResolver,
|
||||
PermissionMiddleware,
|
||||
_normalize_permission_decision,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from app.agents.shared.errors import (
|
||||
CorrectedError,
|
||||
RejectedError,
|
||||
StreamingError,
|
||||
)
|
||||
from app.agents.shared.permissions import (
|
||||
Rule,
|
||||
Ruleset,
|
||||
aggregate_action,
|
||||
evaluate_many,
|
||||
)
|
||||
from app.observability import metrics as ot_metrics, otel as ot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mapping ``tool_name -> resolver`` that converts ``args`` to a list of
|
||||
# patterns to evaluate. The first pattern is conventionally the bare
|
||||
# tool name; later entries narrow down to specific resources.
|
||||
PatternResolver = Callable[[dict[str, Any]], list[str]]
|
||||
|
||||
|
||||
def _default_pattern_resolver(name: str) -> PatternResolver:
|
||||
def _resolve(args: dict[str, Any]) -> list[str]:
|
||||
# Bare name covers the default catch-all; primary-arg fallbacks
|
||||
# are best added per-tool by callers.
|
||||
del args
|
||||
return [name]
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
# Translation from the LangChain HITL envelope (what ``stream_resume_chat``
|
||||
# sends) to SurfSense's legacy ``decision_type`` shape. ``edit`` keeps the
|
||||
# original tool args — tools needing argument edits should use
|
||||
# ``request_approval`` from ``app/agents/new_chat/tools/hitl.py``.
|
||||
_LC_TYPE_TO_PERMISSION_DECISION: dict[str, str] = {
|
||||
"approve": "once",
|
||||
"reject": "reject",
|
||||
"edit": "once",
|
||||
"approve_always": "approve_always",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_permission_decision(decision: Any) -> dict[str, Any]:
|
||||
"""Coerce any accepted reply shape into ``{"decision_type": ..., "feedback"?}``.
|
||||
|
||||
Falls back to ``reject`` (with a warning) on unrecognized payloads so the
|
||||
middleware fails closed.
|
||||
"""
|
||||
if isinstance(decision, str):
|
||||
return {"decision_type": decision}
|
||||
if not isinstance(decision, dict):
|
||||
logger.warning(
|
||||
"Unrecognized permission resume value (%s); treating as reject",
|
||||
type(decision).__name__,
|
||||
)
|
||||
return {"decision_type": "reject"}
|
||||
|
||||
if decision.get("decision_type"):
|
||||
return decision
|
||||
|
||||
payload: dict[str, Any] = decision
|
||||
decisions = decision.get("decisions")
|
||||
if isinstance(decisions, list) and decisions:
|
||||
first = decisions[0]
|
||||
if isinstance(first, dict):
|
||||
payload = first
|
||||
|
||||
raw_type = payload.get("type") or payload.get("decision_type")
|
||||
if not raw_type:
|
||||
logger.warning(
|
||||
"Permission resume missing decision type (keys=%s); treating as reject",
|
||||
list(payload.keys()),
|
||||
)
|
||||
return {"decision_type": "reject"}
|
||||
|
||||
raw_type = str(raw_type).lower()
|
||||
mapped = _LC_TYPE_TO_PERMISSION_DECISION.get(raw_type)
|
||||
if mapped is None:
|
||||
# Tolerate legacy values arriving without ``decision_type`` wrapping.
|
||||
if raw_type in {"once", "approve_always", "reject"}:
|
||||
mapped = raw_type
|
||||
else:
|
||||
logger.warning(
|
||||
"Unknown permission decision type %r; treating as reject", raw_type
|
||||
)
|
||||
mapped = "reject"
|
||||
|
||||
if raw_type == "edit":
|
||||
logger.warning(
|
||||
"Permission middleware received an 'edit' decision; original args "
|
||||
"kept (edits not merged here)."
|
||||
)
|
||||
|
||||
out: dict[str, Any] = {"decision_type": mapped}
|
||||
feedback = payload.get("feedback") or payload.get("message")
|
||||
if isinstance(feedback, str) and feedback.strip():
|
||||
out["feedback"] = feedback
|
||||
return out
|
||||
|
||||
|
||||
class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
||||
"""Allow/deny/ask layer over the agent's tool calls.
|
||||
|
||||
Args:
|
||||
rulesets: Layered rulesets to evaluate. Earlier entries are
|
||||
overridden by later ones (last-match-wins). Typical layering:
|
||||
``defaults < global < space < thread < runtime_approved``.
|
||||
pattern_resolvers: Optional per-tool callables that return a list
|
||||
of patterns to evaluate. When a tool isn't listed, the bare
|
||||
tool name is used as the only pattern.
|
||||
runtime_ruleset: Mutable :class:`Ruleset` that the middleware
|
||||
extends in-place when the user replies ``"approve_always"`` to
|
||||
an ask interrupt. Reused across all calls in the same agent
|
||||
instance so newly-allowed rules apply to subsequent calls.
|
||||
always_emit_interrupt_payload: If True, every ask uses the
|
||||
SurfSense interrupt wire format (default). Set False to
|
||||
disable interrupts and treat ``ask`` as ``deny`` for
|
||||
non-interactive deployments.
|
||||
"""
|
||||
|
||||
tools = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
rulesets: list[Ruleset] | None = None,
|
||||
pattern_resolvers: dict[str, PatternResolver] | None = None,
|
||||
runtime_ruleset: Ruleset | None = None,
|
||||
always_emit_interrupt_payload: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._static_rulesets: list[Ruleset] = list(rulesets or [])
|
||||
self._pattern_resolvers: dict[str, PatternResolver] = dict(
|
||||
pattern_resolvers or {}
|
||||
)
|
||||
self._runtime_ruleset: Ruleset = runtime_ruleset or Ruleset(
|
||||
origin="runtime_approved"
|
||||
)
|
||||
self._emit_interrupt = always_emit_interrupt_payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-filter step (mirrors OpenCode's ``Permission.disabled``)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _globally_denied(self, tool_name: str) -> bool:
|
||||
"""Return True if a deny rule with no narrowing pattern matches."""
|
||||
rules = evaluate_many(tool_name, ["*"], *self._all_rulesets())
|
||||
return aggregate_action(rules) == "deny"
|
||||
|
||||
def _all_rulesets(self) -> list[Ruleset]:
|
||||
return [*self._static_rulesets, self._runtime_ruleset]
|
||||
|
||||
# NOTE: ``before_model`` filtering of the tools list is left to the
|
||||
# agent factory. This middleware only blocks at execution time — and
|
||||
# only via the rule-evaluator path, not by mutating ``request.tools``.
|
||||
# Mutating ``request.tools`` per-call would invalidate provider
|
||||
# prompt-cache prefixes (see Operational risks: prompt-cache regression).
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-call evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_patterns(self, tool_name: str, args: dict[str, Any]) -> list[str]:
|
||||
resolver = self._pattern_resolvers.get(
|
||||
tool_name, _default_pattern_resolver(tool_name)
|
||||
)
|
||||
try:
|
||||
patterns = resolver(args or {})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pattern resolver for %s raised; using bare name", tool_name
|
||||
)
|
||||
patterns = [tool_name]
|
||||
if not patterns:
|
||||
patterns = [tool_name]
|
||||
return patterns
|
||||
|
||||
def _evaluate(
|
||||
self, tool_name: str, args: dict[str, Any]
|
||||
) -> tuple[str, list[str], list[Rule]]:
|
||||
patterns = self._resolve_patterns(tool_name, args)
|
||||
rules = evaluate_many(tool_name, patterns, *self._all_rulesets())
|
||||
action = aggregate_action(rules)
|
||||
return action, patterns, rules
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HITL ask flow — SurfSense wire format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raise_interrupt(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
patterns: list[str],
|
||||
rules: list[Rule],
|
||||
) -> dict[str, Any]:
|
||||
"""Block on user approval via SurfSense's ``interrupt`` shape."""
|
||||
if not self._emit_interrupt:
|
||||
return {"decision_type": "reject"}
|
||||
|
||||
# ``params`` (NOT ``args``) is what SurfSense's streaming
|
||||
# normalizer forwards. Other fields move into ``context``.
|
||||
payload = {
|
||||
"type": "permission_ask",
|
||||
"action": {"tool": tool_name, "params": args or {}},
|
||||
"context": {
|
||||
"patterns": patterns,
|
||||
"rules": [
|
||||
{
|
||||
"permission": r.permission,
|
||||
"pattern": r.pattern,
|
||||
"action": r.action,
|
||||
}
|
||||
for r in rules
|
||||
],
|
||||
# Rules of thumb for the frontend: surface the patterns
|
||||
# the user can promote to "approve_always" with a single reply.
|
||||
"always": patterns,
|
||||
},
|
||||
}
|
||||
# Open ``permission.asked`` + ``interrupt.raised`` OTel spans
|
||||
# (no-op when OTel is disabled) so dashboards can correlate
|
||||
# "we asked X" with "interrupt was actually delivered".
|
||||
with (
|
||||
ot.permission_asked_span(
|
||||
permission=tool_name,
|
||||
pattern=patterns[0] if patterns else None,
|
||||
extra={"permission.patterns": list(patterns)},
|
||||
),
|
||||
ot.interrupt_span(interrupt_type="permission_ask"),
|
||||
):
|
||||
ot_metrics.record_permission_ask(permission=tool_name)
|
||||
ot_metrics.record_interrupt(interrupt_type="permission_ask")
|
||||
decision = interrupt(payload)
|
||||
return _normalize_permission_decision(decision)
|
||||
|
||||
def _persist_always(self, tool_name: str, patterns: list[str]) -> None:
|
||||
"""Promote ``approve_always`` reply into runtime allow rules.
|
||||
|
||||
Persistence to ``agent_permission_rules`` is done by the
|
||||
streaming layer (``stream_new_chat``) once it observes the
|
||||
``approve_always`` reply — the middleware just keeps an
|
||||
in-memory copy so subsequent calls in the same stream see the rule.
|
||||
"""
|
||||
for pattern in patterns:
|
||||
self._runtime_ruleset.rules.append(
|
||||
Rule(permission=tool_name, pattern=pattern, action="allow")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Synthesizing deny -> ToolMessage
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _deny_message(
|
||||
tool_call: dict[str, Any],
|
||||
rule: Rule,
|
||||
) -> ToolMessage:
|
||||
err = StreamingError(
|
||||
code="permission_denied",
|
||||
retryable=False,
|
||||
suggestion=(
|
||||
f"rule permission={rule.permission!r} pattern={rule.pattern!r} "
|
||||
f"blocked this call"
|
||||
),
|
||||
)
|
||||
return ToolMessage(
|
||||
content=(
|
||||
f"Permission denied: rule {rule.permission}/{rule.pattern} "
|
||||
f"blocked tool {tool_call.get('name')!r}."
|
||||
),
|
||||
tool_call_id=tool_call.get("id") or "",
|
||||
name=tool_call.get("name"),
|
||||
status="error",
|
||||
additional_kwargs={"error": err.model_dump()},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The hook: aafter_model
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process(
|
||||
self,
|
||||
state: AgentState,
|
||||
runtime: Runtime[Any],
|
||||
) -> dict[str, Any] | None:
|
||||
del runtime # unused
|
||||
messages = state.get("messages") or []
|
||||
if not messages:
|
||||
return None
|
||||
last = messages[-1]
|
||||
if not isinstance(last, AIMessage) or not last.tool_calls:
|
||||
return None
|
||||
|
||||
deny_messages: list[ToolMessage] = []
|
||||
kept_calls: list[dict[str, Any]] = []
|
||||
any_change = False
|
||||
|
||||
for raw in last.tool_calls:
|
||||
call = (
|
||||
dict(raw)
|
||||
if isinstance(raw, dict)
|
||||
else {
|
||||
"name": getattr(raw, "name", None),
|
||||
"args": getattr(raw, "args", {}),
|
||||
"id": getattr(raw, "id", None),
|
||||
"type": "tool_call",
|
||||
}
|
||||
)
|
||||
name = call.get("name") or ""
|
||||
args = call.get("args") or {}
|
||||
action, patterns, rules = self._evaluate(name, args)
|
||||
|
||||
if action == "deny":
|
||||
# Find the deny rule for the suggestion text
|
||||
deny_rule = next((r for r in rules if r.action == "deny"), rules[0])
|
||||
deny_messages.append(self._deny_message(call, deny_rule))
|
||||
any_change = True
|
||||
continue
|
||||
|
||||
if action == "ask":
|
||||
decision = self._raise_interrupt(
|
||||
tool_name=name, args=args, patterns=patterns, rules=rules
|
||||
)
|
||||
kind = str(decision.get("decision_type") or "reject").lower()
|
||||
if kind == "once":
|
||||
kept_calls.append(call)
|
||||
elif kind == "approve_always":
|
||||
self._persist_always(name, patterns)
|
||||
kept_calls.append(call)
|
||||
elif kind == "reject":
|
||||
feedback = decision.get("feedback")
|
||||
if isinstance(feedback, str) and feedback.strip():
|
||||
raise CorrectedError(feedback, tool=name)
|
||||
raise RejectedError(
|
||||
tool=name, pattern=patterns[0] if patterns else None
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Unknown permission decision %r; treating as reject", kind
|
||||
)
|
||||
raise RejectedError(tool=name)
|
||||
continue
|
||||
|
||||
# allow
|
||||
kept_calls.append(call)
|
||||
|
||||
if not any_change and len(kept_calls) == len(last.tool_calls):
|
||||
return None
|
||||
|
||||
updated = last.model_copy(update={"tool_calls": kept_calls})
|
||||
result_messages: list[Any] = [updated]
|
||||
if deny_messages:
|
||||
result_messages.extend(deny_messages)
|
||||
return {"messages": result_messages}
|
||||
|
||||
def after_model( # type: ignore[override]
|
||||
self, state: AgentState, runtime: Runtime[ContextT]
|
||||
) -> dict[str, Any] | None:
|
||||
return self._process(state, runtime)
|
||||
|
||||
async def aafter_model( # type: ignore[override]
|
||||
self, state: AgentState, runtime: Runtime[ContextT]
|
||||
) -> dict[str, Any] | None:
|
||||
return self._process(state, runtime)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PatternResolver",
|
||||
|
|
|
|||
|
|
@ -1,111 +1,11 @@
|
|||
"""Fallback only on provider/network errors; let programming bugs raise."""
|
||||
"""Backward-compatible shim.
|
||||
|
||||
from __future__ import annotations
|
||||
Moved to ``app.agents.shared.middleware.scoped_model_fallback``. Re-exported here
|
||||
for the frozen single-agent stack (``chat_deepagent``).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain.agents.middleware import ModelFallbackMiddleware
|
||||
|
||||
from app.observability import metrics as ot_metrics, otel as ot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
# Matched by class name across the MRO so we don't have to import every
|
||||
# provider SDK (openai/anthropic/google/...). Extend as new providers ship.
|
||||
_FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"RateLimitError",
|
||||
"APIStatusError",
|
||||
"InternalServerError",
|
||||
"ServiceUnavailableError",
|
||||
"BadGatewayError",
|
||||
"GatewayTimeoutError",
|
||||
"APIConnectionError",
|
||||
"APITimeoutError",
|
||||
"ConnectError",
|
||||
"ConnectTimeout",
|
||||
"ReadTimeout",
|
||||
"RemoteProtocolError",
|
||||
"TimeoutError",
|
||||
"TimeoutException",
|
||||
}
|
||||
from app.agents.shared.middleware.scoped_model_fallback import (
|
||||
ScopedModelFallbackMiddleware,
|
||||
)
|
||||
|
||||
|
||||
def _is_fallback_eligible(exc: BaseException) -> bool:
|
||||
return any(cls.__name__ in _FALLBACK_ELIGIBLE_NAMES for cls in type(exc).__mro__)
|
||||
|
||||
|
||||
class ScopedModelFallbackMiddleware(ModelFallbackMiddleware):
|
||||
"""Re-raise non-provider exceptions instead of walking the fallback chain."""
|
||||
|
||||
def wrap_model_call( # type: ignore[override]
|
||||
self,
|
||||
request: ModelRequest[Any],
|
||||
handler: Callable[[ModelRequest[Any]], ModelResponse[Any]],
|
||||
) -> ModelResponse[Any] | AIMessage:
|
||||
last_exception: Exception
|
||||
try:
|
||||
return handler(request)
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
|
||||
for attempt, fallback_model in enumerate(self.models, start=1):
|
||||
ot.add_event(
|
||||
"model.fallback",
|
||||
{
|
||||
"fallback.attempt": attempt,
|
||||
"fallback.from": attempt - 1,
|
||||
"fallback.to": attempt,
|
||||
"fallback.reason": ot_metrics.categorize_exception(last_exception),
|
||||
},
|
||||
)
|
||||
try:
|
||||
return handler(request.override(model=fallback_model))
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
continue
|
||||
|
||||
raise last_exception
|
||||
|
||||
async def awrap_model_call( # type: ignore[override]
|
||||
self,
|
||||
request: ModelRequest[Any],
|
||||
handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
|
||||
) -> ModelResponse[Any] | AIMessage:
|
||||
last_exception: Exception
|
||||
try:
|
||||
return await handler(request)
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
|
||||
for attempt, fallback_model in enumerate(self.models, start=1):
|
||||
ot.add_event(
|
||||
"model.fallback",
|
||||
{
|
||||
"fallback.attempt": attempt,
|
||||
"fallback.from": attempt - 1,
|
||||
"fallback.to": attempt,
|
||||
"fallback.reason": ot_metrics.categorize_exception(last_exception),
|
||||
},
|
||||
)
|
||||
try:
|
||||
return await handler(request.override(model=fallback_model))
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
continue
|
||||
|
||||
raise last_exception
|
||||
__all__ = ["ScopedModelFallbackMiddleware"]
|
||||
|
|
|
|||
|
|
@ -1,333 +1,17 @@
|
|||
"""Skills backends for SurfSense.
|
||||
"""Backward-compatible shim.
|
||||
|
||||
Implements two minimal :class:`deepagents.backends.protocol.BackendProtocol`
|
||||
subclasses tailored for use with :class:`deepagents.middleware.skills.SkillsMiddleware`.
|
||||
|
||||
The middleware only needs four methods to load skills from a backend:
|
||||
|
||||
* ``ls_info`` / ``als_info`` — list directories under a source path.
|
||||
* ``download_files`` / ``adownload_files`` — fetch ``SKILL.md`` bytes.
|
||||
|
||||
Other ``BackendProtocol`` methods (``read``/``write``/``edit``/``grep_raw`` …)
|
||||
default to ``NotImplementedError`` from the base class. They are never reached
|
||||
by the skills middleware because skill content is rendered into the system
|
||||
prompt at agent build time, not edited at runtime.
|
||||
|
||||
Two backends are provided:
|
||||
|
||||
* :class:`BuiltinSkillsBackend` — disk-backed read of bundled skills from
|
||||
``app/agents/new_chat/skills/builtin/``.
|
||||
* :class:`SearchSpaceSkillsBackend` — a thin read-only wrapper over
|
||||
:class:`KBPostgresBackend` that filters notes under the privileged folder
|
||||
``/documents/_skills/``.
|
||||
|
||||
Both backends are intentionally read-only: skill authoring happens out of band
|
||||
(via filesystem or a search-space-admin route), so we never expose
|
||||
``write`` / ``edit`` / ``upload_files``. The base class' ``NotImplementedError``
|
||||
gives a clean failure mode if anything tries.
|
||||
Moved to ``app.agents.shared.middleware.skills_backends``. Re-exported here for
|
||||
the frozen single-agent stack (``subagents/config``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deepagents.backends.composite import CompositeBackend
|
||||
from deepagents.backends.protocol import (
|
||||
BackendProtocol,
|
||||
FileDownloadResponse,
|
||||
FileInfo,
|
||||
from app.agents.shared.middleware.skills_backends import (
|
||||
SKILLS_BUILTIN_PREFIX,
|
||||
SKILLS_SPACE_PREFIX,
|
||||
BuiltinSkillsBackend,
|
||||
SearchSpaceSkillsBackend,
|
||||
build_skills_backend_factory,
|
||||
default_skills_sources,
|
||||
)
|
||||
from deepagents.backends.state import StateBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain.tools import ToolRuntime
|
||||
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Limit per Agent Skills spec; matches deepagents.middleware.skills.MAX_SKILL_FILE_SIZE.
|
||||
_MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _default_builtin_root() -> Path:
|
||||
"""Return the absolute path to the bundled builtin skills directory.
|
||||
|
||||
Located at ``app/agents/new_chat/skills/builtin/`` relative to this module.
|
||||
"""
|
||||
return (Path(__file__).resolve().parent.parent / "skills" / "builtin").resolve()
|
||||
|
||||
|
||||
class BuiltinSkillsBackend(BackendProtocol):
|
||||
"""Read-only disk-backed skills source.
|
||||
|
||||
Maps a virtual ``/skills/builtin/`` namespace onto a directory on local disk,
|
||||
where each skill is its own subdirectory containing a ``SKILL.md`` file::
|
||||
|
||||
<root>/<skill-name>/SKILL.md
|
||||
|
||||
The middleware calls :meth:`als_info` with the source path and expects a
|
||||
``list[FileInfo]`` whose ``is_dir=True`` entries are descended into. Then it
|
||||
calls :meth:`adownload_files` with the synthesized ``SKILL.md`` paths and
|
||||
parses YAML frontmatter from the returned ``content`` bytes.
|
||||
|
||||
Mounting under :class:`~deepagents.backends.composite.CompositeBackend` at
|
||||
prefix ``/skills/builtin/`` means the middleware can issue paths like
|
||||
``/skills/builtin/kb-research/SKILL.md`` which the composite strips down to
|
||||
``/kb-research/SKILL.md`` before forwarding here. We treat any leading
|
||||
slash as anchoring at :attr:`root`.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path | str | None = None) -> None:
|
||||
self.root: Path = Path(root).resolve() if root else _default_builtin_root()
|
||||
if not self.root.exists():
|
||||
logger.info(
|
||||
"BuiltinSkillsBackend root %s does not exist; skills will be empty.",
|
||||
self.root,
|
||||
)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
"""Resolve a virtual posix path under :attr:`root`, refusing escapes."""
|
||||
bare = path.lstrip("/")
|
||||
candidate = (self.root / bare).resolve() if bare else self.root
|
||||
# Refuse symlink/.. traversal that escapes the root.
|
||||
try:
|
||||
candidate.relative_to(self.root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"path {path!r} escapes builtin skills root") from exc
|
||||
return candidate
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
try:
|
||||
target = self._resolve(path)
|
||||
except ValueError as exc:
|
||||
logger.warning("BuiltinSkillsBackend.ls_info refused: %s", exc)
|
||||
return []
|
||||
if not target.exists() or not target.is_dir():
|
||||
return []
|
||||
|
||||
infos: list[FileInfo] = []
|
||||
# Build virtual paths anchored at "/" because CompositeBackend already
|
||||
# stripped the route prefix before calling us.
|
||||
target_virtual = (
|
||||
"/"
|
||||
if target == self.root
|
||||
else ("/" + str(target.relative_to(self.root)).replace("\\", "/"))
|
||||
)
|
||||
for child in sorted(target.iterdir()):
|
||||
if child.name == "__pycache__" or child.name.startswith("."):
|
||||
continue
|
||||
child_virtual = (
|
||||
target_virtual.rstrip("/") + "/" + child.name
|
||||
if target_virtual != "/"
|
||||
else "/" + child.name
|
||||
)
|
||||
info: FileInfo = {
|
||||
"path": child_virtual,
|
||||
"is_dir": child.is_dir(),
|
||||
}
|
||||
if child.is_file():
|
||||
with contextlib.suppress(OSError): # pragma: no cover - defensive
|
||||
info["size"] = child.stat().st_size
|
||||
infos.append(info)
|
||||
return infos
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
responses: list[FileDownloadResponse] = []
|
||||
for p in paths:
|
||||
try:
|
||||
target = self._resolve(p)
|
||||
except ValueError:
|
||||
responses.append(FileDownloadResponse(path=p, error="invalid_path"))
|
||||
continue
|
||||
if not target.exists():
|
||||
responses.append(FileDownloadResponse(path=p, error="file_not_found"))
|
||||
continue
|
||||
if target.is_dir():
|
||||
responses.append(FileDownloadResponse(path=p, error="is_directory"))
|
||||
continue
|
||||
try:
|
||||
# Hard cap to avoid loading rogue mega-files into memory.
|
||||
size = target.stat().st_size
|
||||
if size > _MAX_SKILL_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Builtin skill file %s exceeds %d bytes; truncating.",
|
||||
target,
|
||||
_MAX_SKILL_FILE_SIZE,
|
||||
)
|
||||
with target.open("rb") as fh:
|
||||
content = fh.read(_MAX_SKILL_FILE_SIZE)
|
||||
else:
|
||||
content = target.read_bytes()
|
||||
except PermissionError:
|
||||
responses.append(
|
||||
FileDownloadResponse(path=p, error="permission_denied")
|
||||
)
|
||||
continue
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.warning("Builtin skill read failed %s: %s", target, exc)
|
||||
responses.append(FileDownloadResponse(path=p, error="file_not_found"))
|
||||
continue
|
||||
responses.append(FileDownloadResponse(path=p, content=content, error=None))
|
||||
return responses
|
||||
|
||||
|
||||
class SearchSpaceSkillsBackend(BackendProtocol):
|
||||
"""Read-only view of search-space-authored skills.
|
||||
|
||||
Wraps a :class:`KBPostgresBackend` and only ever reads under the privileged
|
||||
folder ``/documents/_skills/`` (configurable). The folder is intended to be
|
||||
writable only by search-space admins; this backend never writes.
|
||||
|
||||
The skills middleware expects a layout like::
|
||||
|
||||
/<source_root>/<skill-name>/SKILL.md
|
||||
|
||||
But the KB stores documents like ``/documents/_skills/<name>/SKILL.md``.
|
||||
We expose the inner namespace by remapping each path. When mounted under
|
||||
:class:`CompositeBackend` at prefix ``/skills/space/`` the paths the
|
||||
middleware sees become ``/skills/space/<name>/SKILL.md``; the composite
|
||||
strips ``/skills/space/`` and hands us ``/<name>/SKILL.md``, which we
|
||||
rewrite to ``/documents/_skills/<name>/SKILL.md`` before forwarding to the
|
||||
KB.
|
||||
|
||||
No new database table is needed: the privileged folder convention is
|
||||
enforced server-side outside of this class. We intentionally swallow any
|
||||
write/edit attempts (the base class raises ``NotImplementedError``).
|
||||
"""
|
||||
|
||||
DEFAULT_KB_ROOT: str = "/documents/_skills"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kb_backend: KBPostgresBackend,
|
||||
*,
|
||||
kb_root: str = DEFAULT_KB_ROOT,
|
||||
) -> None:
|
||||
self._kb = kb_backend
|
||||
# Normalize trailing slash off so we can join cleanly.
|
||||
self._kb_root = kb_root.rstrip("/") or "/"
|
||||
|
||||
def _to_kb(self, path: str) -> str:
|
||||
"""Rewrite a virtual path into the underlying KB namespace."""
|
||||
bare = path.lstrip("/")
|
||||
if not bare:
|
||||
return self._kb_root
|
||||
return f"{self._kb_root}/{bare}"
|
||||
|
||||
def _from_kb(self, kb_path: str) -> str:
|
||||
"""Rewrite a KB path back into our virtual namespace."""
|
||||
if not kb_path.startswith(self._kb_root):
|
||||
return kb_path # pragma: no cover - defensive
|
||||
rel = kb_path[len(self._kb_root) :]
|
||||
return rel if rel.startswith("/") else "/" + rel
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
# KBPostgresBackend exposes only the async API meaningfully; the sync
|
||||
# path falls back to ``asyncio.to_thread(...)`` in the base class. We
|
||||
# keep this stub to satisfy abstract resolution; the middleware calls
|
||||
# ``als_info``.
|
||||
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
|
||||
|
||||
async def als_info(self, path: str) -> list[FileInfo]:
|
||||
kb_path = self._to_kb(path)
|
||||
try:
|
||||
infos = await self._kb.als_info(kb_path)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("SearchSpaceSkillsBackend.als_info failed: %s", exc)
|
||||
return []
|
||||
remapped: list[FileInfo] = []
|
||||
for info in infos:
|
||||
kb_p = info.get("path", "")
|
||||
if not kb_p.startswith(self._kb_root):
|
||||
continue
|
||||
remapped.append({**info, "path": self._from_kb(kb_p)})
|
||||
return remapped
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
|
||||
|
||||
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
kb_paths = [self._to_kb(p) for p in paths]
|
||||
responses = await self._kb.adownload_files(kb_paths)
|
||||
# Re-map response paths back to the virtual namespace so the middleware
|
||||
# correlates them to the input list correctly.
|
||||
remapped: list[FileDownloadResponse] = []
|
||||
for original, resp in zip(paths, responses, strict=True):
|
||||
remapped.append(replace(resp, path=original))
|
||||
return remapped
|
||||
|
||||
|
||||
SKILLS_BUILTIN_PREFIX = "/skills/builtin/"
|
||||
SKILLS_SPACE_PREFIX = "/skills/space/"
|
||||
|
||||
|
||||
def build_skills_backend_factory(
|
||||
*,
|
||||
builtin_root: Path | str | None = None,
|
||||
search_space_id: int | None = None,
|
||||
) -> Callable[[ToolRuntime], BackendProtocol]:
|
||||
"""Return a runtime-aware factory for the skills :class:`CompositeBackend`.
|
||||
|
||||
When ``search_space_id`` is provided the composite includes a
|
||||
:class:`SearchSpaceSkillsBackend` route at ``/skills/space/`` over a fresh
|
||||
per-runtime :class:`KBPostgresBackend`, mirroring how
|
||||
:func:`build_backend_resolver` constructs the main filesystem backend.
|
||||
|
||||
When ``search_space_id`` is ``None`` (e.g., desktop-local mode or unit
|
||||
tests) only the bundled :class:`BuiltinSkillsBackend` is exposed.
|
||||
|
||||
Returning a factory rather than a fixed instance is intentional: the
|
||||
underlying KB backend depends on per-call ``ToolRuntime`` state
|
||||
(``staged_dirs``, ``files`` cache, runtime config), so a single shared
|
||||
instance cannot serve multiple concurrent agent runs.
|
||||
"""
|
||||
builtin = BuiltinSkillsBackend(builtin_root)
|
||||
|
||||
if search_space_id is None:
|
||||
|
||||
def _factory_builtin_only(runtime: ToolRuntime) -> BackendProtocol:
|
||||
# Default StateBackend is intentionally inert: any path outside the
|
||||
# ``/skills/builtin/`` route resolves to an empty per-runtime state
|
||||
# so the SkillsMiddleware can iterate sources without raising.
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={SKILLS_BUILTIN_PREFIX: builtin},
|
||||
)
|
||||
|
||||
return _factory_builtin_only
|
||||
|
||||
def _factory_with_space(runtime: ToolRuntime) -> BackendProtocol:
|
||||
# Imported lazily to avoid a hard dependency at module import time:
|
||||
# ``KBPostgresBackend`` pulls in DB models, which are unnecessary for
|
||||
# the unit-tested builtin path.
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
|
||||
kb = KBPostgresBackend(search_space_id, runtime)
|
||||
space = SearchSpaceSkillsBackend(kb)
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={
|
||||
SKILLS_BUILTIN_PREFIX: builtin,
|
||||
SKILLS_SPACE_PREFIX: space,
|
||||
},
|
||||
)
|
||||
|
||||
return _factory_with_space
|
||||
|
||||
|
||||
def default_skills_sources() -> list[str]:
|
||||
"""Return the canonical source list for SkillsMiddleware (built-in then space)."""
|
||||
return [SKILLS_BUILTIN_PREFIX, SKILLS_SPACE_PREFIX]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SKILLS_BUILTIN_PREFIX",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from sqlalchemy import cast, select
|
|||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args
|
||||
from app.agents.shared.middleware.dedup_tool_calls import dedup_key_full_args
|
||||
from app.agents.new_chat.tools.hitl import request_approval
|
||||
from app.agents.new_chat.tools.mcp_client import MCPClient
|
||||
from app.agents.new_chat.tools.mcp_tools_cache import (
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ from typing import Any
|
|||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.new_chat.middleware.dedup_tool_calls import (
|
||||
from app.agents.shared.middleware.dedup_tool_calls import (
|
||||
wrap_dedup_key_by_arg_name,
|
||||
)
|
||||
from app.db import ChatVisibility
|
||||
|
|
|
|||
87
surfsense_backend/app/agents/shared/middleware/__init__.py
Normal file
87
surfsense_backend/app/agents/shared/middleware/__init__.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Middleware components for the SurfSense new chat agent."""
|
||||
|
||||
from app.agents.shared.middleware.action_log import ActionLogMiddleware
|
||||
from app.agents.shared.middleware.anonymous_document import (
|
||||
AnonymousDocumentMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.busy_mutex import BusyMutexMiddleware
|
||||
from app.agents.shared.middleware.compaction import (
|
||||
SurfSenseCompactionMiddleware,
|
||||
create_surfsense_compaction_middleware,
|
||||
)
|
||||
from app.agents.shared.middleware.context_editing import (
|
||||
ClearToolUsesEdit,
|
||||
SpillingContextEditingMiddleware,
|
||||
SpillToBackendEdit,
|
||||
)
|
||||
from app.agents.shared.middleware.dedup_tool_calls import (
|
||||
DedupHITLToolCallsMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.doom_loop import DoomLoopMiddleware
|
||||
from app.agents.shared.middleware.file_intent import (
|
||||
FileIntentMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.filesystem import (
|
||||
SurfSenseFilesystemMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.flatten_system import (
|
||||
FlattenSystemMessageMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.kb_persistence import (
|
||||
KnowledgeBasePersistenceMiddleware,
|
||||
commit_staged_filesystem_state,
|
||||
)
|
||||
from app.agents.shared.middleware.knowledge_search import (
|
||||
KnowledgeBaseSearchMiddleware,
|
||||
KnowledgePriorityMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.knowledge_tree import (
|
||||
KnowledgeTreeMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.memory_injection import (
|
||||
MemoryInjectionMiddleware,
|
||||
)
|
||||
from app.agents.shared.middleware.noop_injection import NoopInjectionMiddleware
|
||||
from app.agents.shared.middleware.otel_span import OtelSpanMiddleware
|
||||
from app.agents.shared.middleware.permission import PermissionMiddleware
|
||||
from app.agents.shared.middleware.retry_after import RetryAfterMiddleware
|
||||
from app.agents.shared.middleware.skills_backends import (
|
||||
BuiltinSkillsBackend,
|
||||
SearchSpaceSkillsBackend,
|
||||
build_skills_backend_factory,
|
||||
default_skills_sources,
|
||||
)
|
||||
from app.agents.shared.middleware.tool_call_repair import (
|
||||
ToolCallNameRepairMiddleware,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActionLogMiddleware",
|
||||
"AnonymousDocumentMiddleware",
|
||||
"BuiltinSkillsBackend",
|
||||
"BusyMutexMiddleware",
|
||||
"ClearToolUsesEdit",
|
||||
"DedupHITLToolCallsMiddleware",
|
||||
"DoomLoopMiddleware",
|
||||
"FileIntentMiddleware",
|
||||
"FlattenSystemMessageMiddleware",
|
||||
"KnowledgeBasePersistenceMiddleware",
|
||||
"KnowledgeBaseSearchMiddleware",
|
||||
"KnowledgePriorityMiddleware",
|
||||
"KnowledgeTreeMiddleware",
|
||||
"MemoryInjectionMiddleware",
|
||||
"NoopInjectionMiddleware",
|
||||
"OtelSpanMiddleware",
|
||||
"PermissionMiddleware",
|
||||
"RetryAfterMiddleware",
|
||||
"SearchSpaceSkillsBackend",
|
||||
"SpillToBackendEdit",
|
||||
"SpillingContextEditingMiddleware",
|
||||
"SurfSenseCompactionMiddleware",
|
||||
"SurfSenseFilesystemMiddleware",
|
||||
"ToolCallNameRepairMiddleware",
|
||||
"build_skills_backend_factory",
|
||||
"commit_staged_filesystem_state",
|
||||
"create_surfsense_compaction_middleware",
|
||||
"default_skills_sources",
|
||||
]
|
||||
|
|
@ -34,12 +34,16 @@ from langchain_core.callbacks import adispatch_custom_event
|
|||
from langchain_core.messages import ToolMessage
|
||||
|
||||
from app.agents.shared.feature_flags import get_flags
|
||||
from app.agents.new_chat.tools.registry import ToolDefinition
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type-only
|
||||
from langchain.agents.middleware.types import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
# Type-only import: keeping it lazy avoids a module-load cycle through the
|
||||
# frozen single-agent package (new_chat.__init__ -> chat_deepagent ->
|
||||
# middleware shim). Resolves to app.agents.shared.tools once tools migrate.
|
||||
from app.agents.new_chat.tools.registry import ToolDefinition
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -48,11 +48,11 @@ from langgraph.types import Command
|
|||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.shared.filesystem_state import SurfSenseFilesystemState
|
||||
from app.agents.new_chat.middleware.kb_postgres_backend import (
|
||||
from app.agents.shared.middleware.kb_postgres_backend import (
|
||||
KBPostgresBackend,
|
||||
paginate_listing,
|
||||
)
|
||||
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
|
||||
from app.agents.shared.middleware.multi_root_local_folder_backend import (
|
||||
MultiRootLocalFolderBackend,
|
||||
)
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
|
|
@ -634,7 +634,7 @@ class KnowledgePriorityMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
if not flags.enable_kb_planner_runnable or flags.disable_new_agent_stack:
|
||||
return None
|
||||
|
||||
from app.agents.new_chat.middleware.retry_after import RetryAfterMiddleware
|
||||
from app.agents.shared.middleware.retry_after import RetryAfterMiddleware
|
||||
|
||||
try:
|
||||
self._planner = create_agent(
|
||||
|
|
@ -15,7 +15,7 @@ from deepagents.backends.protocol import (
|
|||
WriteResult,
|
||||
)
|
||||
|
||||
from app.agents.new_chat.middleware.local_folder_backend import LocalFolderBackend
|
||||
from app.agents.shared.middleware.local_folder_backend import LocalFolderBackend
|
||||
|
||||
_INVALID_PATH = "invalid_path"
|
||||
_FILE_NOT_FOUND = "file_not_found"
|
||||
427
surfsense_backend/app/agents/shared/middleware/permission.py
Normal file
427
surfsense_backend/app/agents/shared/middleware/permission.py
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
"""
|
||||
PermissionMiddleware — pattern-based allow/deny/ask with HITL fallback.
|
||||
|
||||
LangChain's :class:`HumanInTheLoopMiddleware` only supports a static
|
||||
"this tool always asks" decision per tool. There's no rule-based
|
||||
allow/deny/ask layered ruleset, no glob patterns, no per-search-space or
|
||||
per-thread overrides, and no auto-deny synthesis.
|
||||
|
||||
This middleware ports OpenCode's ``packages/opencode/src/permission/index.ts``
|
||||
ruleset model on top of SurfSense's existing ``interrupt({type, action,
|
||||
context})`` payload shape (see ``app/agents/new_chat/tools/hitl.py``) so
|
||||
the frontend keeps working unchanged.
|
||||
|
||||
Operation:
|
||||
1. ``aafter_model`` inspects the latest ``AIMessage.tool_calls``.
|
||||
2. For each call, the middleware builds a list of ``patterns`` (the
|
||||
tool name plus any tool-specific patterns from the resolver). It
|
||||
evaluates each pattern against the layered rulesets and aggregates
|
||||
the results: ``deny`` > ``ask`` > ``allow``.
|
||||
3. On ``deny``: replaces the call with a synthetic ``ToolMessage``
|
||||
containing a :class:`StreamingError`.
|
||||
4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. Both the legacy
|
||||
SurfSense shape and LangChain HITL ``{"decisions": [{"type": ...}]}``
|
||||
replies are accepted via :func:`_normalize_permission_decision`.
|
||||
- ``once``: proceed.
|
||||
- ``approve_always``: also persist allow rules for ``request.always`` patterns.
|
||||
- ``reject`` w/o feedback: raise :class:`RejectedError`.
|
||||
- ``reject`` w/ feedback: raise :class:`CorrectedError`.
|
||||
5. On ``allow``: proceed unchanged.
|
||||
|
||||
The middleware also performs a *pre-model* tool-filter step (the
|
||||
``before_model`` hook) so globally denied tools are stripped from the
|
||||
exposed tool list before the model gets to see them. This mirrors
|
||||
OpenCode's ``Permission.disabled`` and dramatically reduces the chance
|
||||
the model emits a deny-only call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ContextT,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from app.agents.shared.errors import (
|
||||
CorrectedError,
|
||||
RejectedError,
|
||||
StreamingError,
|
||||
)
|
||||
from app.agents.shared.permissions import (
|
||||
Rule,
|
||||
Ruleset,
|
||||
aggregate_action,
|
||||
evaluate_many,
|
||||
)
|
||||
from app.observability import metrics as ot_metrics, otel as ot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mapping ``tool_name -> resolver`` that converts ``args`` to a list of
|
||||
# patterns to evaluate. The first pattern is conventionally the bare
|
||||
# tool name; later entries narrow down to specific resources.
|
||||
PatternResolver = Callable[[dict[str, Any]], list[str]]
|
||||
|
||||
|
||||
def _default_pattern_resolver(name: str) -> PatternResolver:
|
||||
def _resolve(args: dict[str, Any]) -> list[str]:
|
||||
# Bare name covers the default catch-all; primary-arg fallbacks
|
||||
# are best added per-tool by callers.
|
||||
del args
|
||||
return [name]
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
# Translation from the LangChain HITL envelope (what ``stream_resume_chat``
|
||||
# sends) to SurfSense's legacy ``decision_type`` shape. ``edit`` keeps the
|
||||
# original tool args — tools needing argument edits should use
|
||||
# ``request_approval`` from ``app/agents/new_chat/tools/hitl.py``.
|
||||
_LC_TYPE_TO_PERMISSION_DECISION: dict[str, str] = {
|
||||
"approve": "once",
|
||||
"reject": "reject",
|
||||
"edit": "once",
|
||||
"approve_always": "approve_always",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_permission_decision(decision: Any) -> dict[str, Any]:
|
||||
"""Coerce any accepted reply shape into ``{"decision_type": ..., "feedback"?}``.
|
||||
|
||||
Falls back to ``reject`` (with a warning) on unrecognized payloads so the
|
||||
middleware fails closed.
|
||||
"""
|
||||
if isinstance(decision, str):
|
||||
return {"decision_type": decision}
|
||||
if not isinstance(decision, dict):
|
||||
logger.warning(
|
||||
"Unrecognized permission resume value (%s); treating as reject",
|
||||
type(decision).__name__,
|
||||
)
|
||||
return {"decision_type": "reject"}
|
||||
|
||||
if decision.get("decision_type"):
|
||||
return decision
|
||||
|
||||
payload: dict[str, Any] = decision
|
||||
decisions = decision.get("decisions")
|
||||
if isinstance(decisions, list) and decisions:
|
||||
first = decisions[0]
|
||||
if isinstance(first, dict):
|
||||
payload = first
|
||||
|
||||
raw_type = payload.get("type") or payload.get("decision_type")
|
||||
if not raw_type:
|
||||
logger.warning(
|
||||
"Permission resume missing decision type (keys=%s); treating as reject",
|
||||
list(payload.keys()),
|
||||
)
|
||||
return {"decision_type": "reject"}
|
||||
|
||||
raw_type = str(raw_type).lower()
|
||||
mapped = _LC_TYPE_TO_PERMISSION_DECISION.get(raw_type)
|
||||
if mapped is None:
|
||||
# Tolerate legacy values arriving without ``decision_type`` wrapping.
|
||||
if raw_type in {"once", "approve_always", "reject"}:
|
||||
mapped = raw_type
|
||||
else:
|
||||
logger.warning(
|
||||
"Unknown permission decision type %r; treating as reject", raw_type
|
||||
)
|
||||
mapped = "reject"
|
||||
|
||||
if raw_type == "edit":
|
||||
logger.warning(
|
||||
"Permission middleware received an 'edit' decision; original args "
|
||||
"kept (edits not merged here)."
|
||||
)
|
||||
|
||||
out: dict[str, Any] = {"decision_type": mapped}
|
||||
feedback = payload.get("feedback") or payload.get("message")
|
||||
if isinstance(feedback, str) and feedback.strip():
|
||||
out["feedback"] = feedback
|
||||
return out
|
||||
|
||||
|
||||
class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
||||
"""Allow/deny/ask layer over the agent's tool calls.
|
||||
|
||||
Args:
|
||||
rulesets: Layered rulesets to evaluate. Earlier entries are
|
||||
overridden by later ones (last-match-wins). Typical layering:
|
||||
``defaults < global < space < thread < runtime_approved``.
|
||||
pattern_resolvers: Optional per-tool callables that return a list
|
||||
of patterns to evaluate. When a tool isn't listed, the bare
|
||||
tool name is used as the only pattern.
|
||||
runtime_ruleset: Mutable :class:`Ruleset` that the middleware
|
||||
extends in-place when the user replies ``"approve_always"`` to
|
||||
an ask interrupt. Reused across all calls in the same agent
|
||||
instance so newly-allowed rules apply to subsequent calls.
|
||||
always_emit_interrupt_payload: If True, every ask uses the
|
||||
SurfSense interrupt wire format (default). Set False to
|
||||
disable interrupts and treat ``ask`` as ``deny`` for
|
||||
non-interactive deployments.
|
||||
"""
|
||||
|
||||
tools = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
rulesets: list[Ruleset] | None = None,
|
||||
pattern_resolvers: dict[str, PatternResolver] | None = None,
|
||||
runtime_ruleset: Ruleset | None = None,
|
||||
always_emit_interrupt_payload: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._static_rulesets: list[Ruleset] = list(rulesets or [])
|
||||
self._pattern_resolvers: dict[str, PatternResolver] = dict(
|
||||
pattern_resolvers or {}
|
||||
)
|
||||
self._runtime_ruleset: Ruleset = runtime_ruleset or Ruleset(
|
||||
origin="runtime_approved"
|
||||
)
|
||||
self._emit_interrupt = always_emit_interrupt_payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-filter step (mirrors OpenCode's ``Permission.disabled``)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _globally_denied(self, tool_name: str) -> bool:
|
||||
"""Return True if a deny rule with no narrowing pattern matches."""
|
||||
rules = evaluate_many(tool_name, ["*"], *self._all_rulesets())
|
||||
return aggregate_action(rules) == "deny"
|
||||
|
||||
def _all_rulesets(self) -> list[Ruleset]:
|
||||
return [*self._static_rulesets, self._runtime_ruleset]
|
||||
|
||||
# NOTE: ``before_model`` filtering of the tools list is left to the
|
||||
# agent factory. This middleware only blocks at execution time — and
|
||||
# only via the rule-evaluator path, not by mutating ``request.tools``.
|
||||
# Mutating ``request.tools`` per-call would invalidate provider
|
||||
# prompt-cache prefixes (see Operational risks: prompt-cache regression).
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-call evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_patterns(self, tool_name: str, args: dict[str, Any]) -> list[str]:
|
||||
resolver = self._pattern_resolvers.get(
|
||||
tool_name, _default_pattern_resolver(tool_name)
|
||||
)
|
||||
try:
|
||||
patterns = resolver(args or {})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pattern resolver for %s raised; using bare name", tool_name
|
||||
)
|
||||
patterns = [tool_name]
|
||||
if not patterns:
|
||||
patterns = [tool_name]
|
||||
return patterns
|
||||
|
||||
def _evaluate(
|
||||
self, tool_name: str, args: dict[str, Any]
|
||||
) -> tuple[str, list[str], list[Rule]]:
|
||||
patterns = self._resolve_patterns(tool_name, args)
|
||||
rules = evaluate_many(tool_name, patterns, *self._all_rulesets())
|
||||
action = aggregate_action(rules)
|
||||
return action, patterns, rules
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HITL ask flow — SurfSense wire format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raise_interrupt(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
patterns: list[str],
|
||||
rules: list[Rule],
|
||||
) -> dict[str, Any]:
|
||||
"""Block on user approval via SurfSense's ``interrupt`` shape."""
|
||||
if not self._emit_interrupt:
|
||||
return {"decision_type": "reject"}
|
||||
|
||||
# ``params`` (NOT ``args``) is what SurfSense's streaming
|
||||
# normalizer forwards. Other fields move into ``context``.
|
||||
payload = {
|
||||
"type": "permission_ask",
|
||||
"action": {"tool": tool_name, "params": args or {}},
|
||||
"context": {
|
||||
"patterns": patterns,
|
||||
"rules": [
|
||||
{
|
||||
"permission": r.permission,
|
||||
"pattern": r.pattern,
|
||||
"action": r.action,
|
||||
}
|
||||
for r in rules
|
||||
],
|
||||
# Rules of thumb for the frontend: surface the patterns
|
||||
# the user can promote to "approve_always" with a single reply.
|
||||
"always": patterns,
|
||||
},
|
||||
}
|
||||
# Open ``permission.asked`` + ``interrupt.raised`` OTel spans
|
||||
# (no-op when OTel is disabled) so dashboards can correlate
|
||||
# "we asked X" with "interrupt was actually delivered".
|
||||
with (
|
||||
ot.permission_asked_span(
|
||||
permission=tool_name,
|
||||
pattern=patterns[0] if patterns else None,
|
||||
extra={"permission.patterns": list(patterns)},
|
||||
),
|
||||
ot.interrupt_span(interrupt_type="permission_ask"),
|
||||
):
|
||||
ot_metrics.record_permission_ask(permission=tool_name)
|
||||
ot_metrics.record_interrupt(interrupt_type="permission_ask")
|
||||
decision = interrupt(payload)
|
||||
return _normalize_permission_decision(decision)
|
||||
|
||||
def _persist_always(self, tool_name: str, patterns: list[str]) -> None:
|
||||
"""Promote ``approve_always`` reply into runtime allow rules.
|
||||
|
||||
Persistence to ``agent_permission_rules`` is done by the
|
||||
streaming layer (``stream_new_chat``) once it observes the
|
||||
``approve_always`` reply — the middleware just keeps an
|
||||
in-memory copy so subsequent calls in the same stream see the rule.
|
||||
"""
|
||||
for pattern in patterns:
|
||||
self._runtime_ruleset.rules.append(
|
||||
Rule(permission=tool_name, pattern=pattern, action="allow")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Synthesizing deny -> ToolMessage
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _deny_message(
|
||||
tool_call: dict[str, Any],
|
||||
rule: Rule,
|
||||
) -> ToolMessage:
|
||||
err = StreamingError(
|
||||
code="permission_denied",
|
||||
retryable=False,
|
||||
suggestion=(
|
||||
f"rule permission={rule.permission!r} pattern={rule.pattern!r} "
|
||||
f"blocked this call"
|
||||
),
|
||||
)
|
||||
return ToolMessage(
|
||||
content=(
|
||||
f"Permission denied: rule {rule.permission}/{rule.pattern} "
|
||||
f"blocked tool {tool_call.get('name')!r}."
|
||||
),
|
||||
tool_call_id=tool_call.get("id") or "",
|
||||
name=tool_call.get("name"),
|
||||
status="error",
|
||||
additional_kwargs={"error": err.model_dump()},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The hook: aafter_model
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process(
|
||||
self,
|
||||
state: AgentState,
|
||||
runtime: Runtime[Any],
|
||||
) -> dict[str, Any] | None:
|
||||
del runtime # unused
|
||||
messages = state.get("messages") or []
|
||||
if not messages:
|
||||
return None
|
||||
last = messages[-1]
|
||||
if not isinstance(last, AIMessage) or not last.tool_calls:
|
||||
return None
|
||||
|
||||
deny_messages: list[ToolMessage] = []
|
||||
kept_calls: list[dict[str, Any]] = []
|
||||
any_change = False
|
||||
|
||||
for raw in last.tool_calls:
|
||||
call = (
|
||||
dict(raw)
|
||||
if isinstance(raw, dict)
|
||||
else {
|
||||
"name": getattr(raw, "name", None),
|
||||
"args": getattr(raw, "args", {}),
|
||||
"id": getattr(raw, "id", None),
|
||||
"type": "tool_call",
|
||||
}
|
||||
)
|
||||
name = call.get("name") or ""
|
||||
args = call.get("args") or {}
|
||||
action, patterns, rules = self._evaluate(name, args)
|
||||
|
||||
if action == "deny":
|
||||
# Find the deny rule for the suggestion text
|
||||
deny_rule = next((r for r in rules if r.action == "deny"), rules[0])
|
||||
deny_messages.append(self._deny_message(call, deny_rule))
|
||||
any_change = True
|
||||
continue
|
||||
|
||||
if action == "ask":
|
||||
decision = self._raise_interrupt(
|
||||
tool_name=name, args=args, patterns=patterns, rules=rules
|
||||
)
|
||||
kind = str(decision.get("decision_type") or "reject").lower()
|
||||
if kind == "once":
|
||||
kept_calls.append(call)
|
||||
elif kind == "approve_always":
|
||||
self._persist_always(name, patterns)
|
||||
kept_calls.append(call)
|
||||
elif kind == "reject":
|
||||
feedback = decision.get("feedback")
|
||||
if isinstance(feedback, str) and feedback.strip():
|
||||
raise CorrectedError(feedback, tool=name)
|
||||
raise RejectedError(
|
||||
tool=name, pattern=patterns[0] if patterns else None
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Unknown permission decision %r; treating as reject", kind
|
||||
)
|
||||
raise RejectedError(tool=name)
|
||||
continue
|
||||
|
||||
# allow
|
||||
kept_calls.append(call)
|
||||
|
||||
if not any_change and len(kept_calls) == len(last.tool_calls):
|
||||
return None
|
||||
|
||||
updated = last.model_copy(update={"tool_calls": kept_calls})
|
||||
result_messages: list[Any] = [updated]
|
||||
if deny_messages:
|
||||
result_messages.extend(deny_messages)
|
||||
return {"messages": result_messages}
|
||||
|
||||
def after_model( # type: ignore[override]
|
||||
self, state: AgentState, runtime: Runtime[ContextT]
|
||||
) -> dict[str, Any] | None:
|
||||
return self._process(state, runtime)
|
||||
|
||||
async def aafter_model( # type: ignore[override]
|
||||
self, state: AgentState, runtime: Runtime[ContextT]
|
||||
) -> dict[str, Any] | None:
|
||||
return self._process(state, runtime)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PatternResolver",
|
||||
"PermissionMiddleware",
|
||||
"_normalize_permission_decision",
|
||||
]
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"""Fallback only on provider/network errors; let programming bugs raise."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain.agents.middleware import ModelFallbackMiddleware
|
||||
|
||||
from app.observability import metrics as ot_metrics, otel as ot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
# Matched by class name across the MRO so we don't have to import every
|
||||
# provider SDK (openai/anthropic/google/...). Extend as new providers ship.
|
||||
_FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"RateLimitError",
|
||||
"APIStatusError",
|
||||
"InternalServerError",
|
||||
"ServiceUnavailableError",
|
||||
"BadGatewayError",
|
||||
"GatewayTimeoutError",
|
||||
"APIConnectionError",
|
||||
"APITimeoutError",
|
||||
"ConnectError",
|
||||
"ConnectTimeout",
|
||||
"ReadTimeout",
|
||||
"RemoteProtocolError",
|
||||
"TimeoutError",
|
||||
"TimeoutException",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_fallback_eligible(exc: BaseException) -> bool:
|
||||
return any(cls.__name__ in _FALLBACK_ELIGIBLE_NAMES for cls in type(exc).__mro__)
|
||||
|
||||
|
||||
class ScopedModelFallbackMiddleware(ModelFallbackMiddleware):
|
||||
"""Re-raise non-provider exceptions instead of walking the fallback chain."""
|
||||
|
||||
def wrap_model_call( # type: ignore[override]
|
||||
self,
|
||||
request: ModelRequest[Any],
|
||||
handler: Callable[[ModelRequest[Any]], ModelResponse[Any]],
|
||||
) -> ModelResponse[Any] | AIMessage:
|
||||
last_exception: Exception
|
||||
try:
|
||||
return handler(request)
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
|
||||
for attempt, fallback_model in enumerate(self.models, start=1):
|
||||
ot.add_event(
|
||||
"model.fallback",
|
||||
{
|
||||
"fallback.attempt": attempt,
|
||||
"fallback.from": attempt - 1,
|
||||
"fallback.to": attempt,
|
||||
"fallback.reason": ot_metrics.categorize_exception(last_exception),
|
||||
},
|
||||
)
|
||||
try:
|
||||
return handler(request.override(model=fallback_model))
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
continue
|
||||
|
||||
raise last_exception
|
||||
|
||||
async def awrap_model_call( # type: ignore[override]
|
||||
self,
|
||||
request: ModelRequest[Any],
|
||||
handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
|
||||
) -> ModelResponse[Any] | AIMessage:
|
||||
last_exception: Exception
|
||||
try:
|
||||
return await handler(request)
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
|
||||
for attempt, fallback_model in enumerate(self.models, start=1):
|
||||
ot.add_event(
|
||||
"model.fallback",
|
||||
{
|
||||
"fallback.attempt": attempt,
|
||||
"fallback.from": attempt - 1,
|
||||
"fallback.to": attempt,
|
||||
"fallback.reason": ot_metrics.categorize_exception(last_exception),
|
||||
},
|
||||
)
|
||||
try:
|
||||
return await handler(request.override(model=fallback_model))
|
||||
except Exception as e:
|
||||
if not _is_fallback_eligible(e):
|
||||
raise
|
||||
last_exception = e
|
||||
continue
|
||||
|
||||
raise last_exception
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
"""Skills backends for SurfSense.
|
||||
|
||||
Implements two minimal :class:`deepagents.backends.protocol.BackendProtocol`
|
||||
subclasses tailored for use with :class:`deepagents.middleware.skills.SkillsMiddleware`.
|
||||
|
||||
The middleware only needs four methods to load skills from a backend:
|
||||
|
||||
* ``ls_info`` / ``als_info`` — list directories under a source path.
|
||||
* ``download_files`` / ``adownload_files`` — fetch ``SKILL.md`` bytes.
|
||||
|
||||
Other ``BackendProtocol`` methods (``read``/``write``/``edit``/``grep_raw`` …)
|
||||
default to ``NotImplementedError`` from the base class. They are never reached
|
||||
by the skills middleware because skill content is rendered into the system
|
||||
prompt at agent build time, not edited at runtime.
|
||||
|
||||
Two backends are provided:
|
||||
|
||||
* :class:`BuiltinSkillsBackend` — disk-backed read of bundled skills from
|
||||
``app/agents/new_chat/skills/builtin/``.
|
||||
* :class:`SearchSpaceSkillsBackend` — a thin read-only wrapper over
|
||||
:class:`KBPostgresBackend` that filters notes under the privileged folder
|
||||
``/documents/_skills/``.
|
||||
|
||||
Both backends are intentionally read-only: skill authoring happens out of band
|
||||
(via filesystem or a search-space-admin route), so we never expose
|
||||
``write`` / ``edit`` / ``upload_files``. The base class' ``NotImplementedError``
|
||||
gives a clean failure mode if anything tries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deepagents.backends.composite import CompositeBackend
|
||||
from deepagents.backends.protocol import (
|
||||
BackendProtocol,
|
||||
FileDownloadResponse,
|
||||
FileInfo,
|
||||
)
|
||||
from deepagents.backends.state import StateBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain.tools import ToolRuntime
|
||||
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Limit per Agent Skills spec; matches deepagents.middleware.skills.MAX_SKILL_FILE_SIZE.
|
||||
_MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _default_builtin_root() -> Path:
|
||||
"""Return the absolute path to the bundled builtin skills directory.
|
||||
|
||||
The skill assets still live at ``app/agents/new_chat/skills/builtin/`` (the
|
||||
``skills/`` tree migrates to the shared kernel in a later slice). This module
|
||||
now lives under ``app/agents/shared/middleware/``, so we walk up to
|
||||
``app/agents/`` and back into ``new_chat/skills/builtin``. Once skills move,
|
||||
this becomes ``Path(__file__).resolve().parent.parent / "skills" / "builtin"``.
|
||||
"""
|
||||
agents_dir = Path(__file__).resolve().parent.parent.parent
|
||||
return (agents_dir / "new_chat" / "skills" / "builtin").resolve()
|
||||
|
||||
|
||||
class BuiltinSkillsBackend(BackendProtocol):
|
||||
"""Read-only disk-backed skills source.
|
||||
|
||||
Maps a virtual ``/skills/builtin/`` namespace onto a directory on local disk,
|
||||
where each skill is its own subdirectory containing a ``SKILL.md`` file::
|
||||
|
||||
<root>/<skill-name>/SKILL.md
|
||||
|
||||
The middleware calls :meth:`als_info` with the source path and expects a
|
||||
``list[FileInfo]`` whose ``is_dir=True`` entries are descended into. Then it
|
||||
calls :meth:`adownload_files` with the synthesized ``SKILL.md`` paths and
|
||||
parses YAML frontmatter from the returned ``content`` bytes.
|
||||
|
||||
Mounting under :class:`~deepagents.backends.composite.CompositeBackend` at
|
||||
prefix ``/skills/builtin/`` means the middleware can issue paths like
|
||||
``/skills/builtin/kb-research/SKILL.md`` which the composite strips down to
|
||||
``/kb-research/SKILL.md`` before forwarding here. We treat any leading
|
||||
slash as anchoring at :attr:`root`.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path | str | None = None) -> None:
|
||||
self.root: Path = Path(root).resolve() if root else _default_builtin_root()
|
||||
if not self.root.exists():
|
||||
logger.info(
|
||||
"BuiltinSkillsBackend root %s does not exist; skills will be empty.",
|
||||
self.root,
|
||||
)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
"""Resolve a virtual posix path under :attr:`root`, refusing escapes."""
|
||||
bare = path.lstrip("/")
|
||||
candidate = (self.root / bare).resolve() if bare else self.root
|
||||
# Refuse symlink/.. traversal that escapes the root.
|
||||
try:
|
||||
candidate.relative_to(self.root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"path {path!r} escapes builtin skills root") from exc
|
||||
return candidate
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
try:
|
||||
target = self._resolve(path)
|
||||
except ValueError as exc:
|
||||
logger.warning("BuiltinSkillsBackend.ls_info refused: %s", exc)
|
||||
return []
|
||||
if not target.exists() or not target.is_dir():
|
||||
return []
|
||||
|
||||
infos: list[FileInfo] = []
|
||||
# Build virtual paths anchored at "/" because CompositeBackend already
|
||||
# stripped the route prefix before calling us.
|
||||
target_virtual = (
|
||||
"/"
|
||||
if target == self.root
|
||||
else ("/" + str(target.relative_to(self.root)).replace("\\", "/"))
|
||||
)
|
||||
for child in sorted(target.iterdir()):
|
||||
if child.name == "__pycache__" or child.name.startswith("."):
|
||||
continue
|
||||
child_virtual = (
|
||||
target_virtual.rstrip("/") + "/" + child.name
|
||||
if target_virtual != "/"
|
||||
else "/" + child.name
|
||||
)
|
||||
info: FileInfo = {
|
||||
"path": child_virtual,
|
||||
"is_dir": child.is_dir(),
|
||||
}
|
||||
if child.is_file():
|
||||
with contextlib.suppress(OSError): # pragma: no cover - defensive
|
||||
info["size"] = child.stat().st_size
|
||||
infos.append(info)
|
||||
return infos
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
responses: list[FileDownloadResponse] = []
|
||||
for p in paths:
|
||||
try:
|
||||
target = self._resolve(p)
|
||||
except ValueError:
|
||||
responses.append(FileDownloadResponse(path=p, error="invalid_path"))
|
||||
continue
|
||||
if not target.exists():
|
||||
responses.append(FileDownloadResponse(path=p, error="file_not_found"))
|
||||
continue
|
||||
if target.is_dir():
|
||||
responses.append(FileDownloadResponse(path=p, error="is_directory"))
|
||||
continue
|
||||
try:
|
||||
# Hard cap to avoid loading rogue mega-files into memory.
|
||||
size = target.stat().st_size
|
||||
if size > _MAX_SKILL_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Builtin skill file %s exceeds %d bytes; truncating.",
|
||||
target,
|
||||
_MAX_SKILL_FILE_SIZE,
|
||||
)
|
||||
with target.open("rb") as fh:
|
||||
content = fh.read(_MAX_SKILL_FILE_SIZE)
|
||||
else:
|
||||
content = target.read_bytes()
|
||||
except PermissionError:
|
||||
responses.append(
|
||||
FileDownloadResponse(path=p, error="permission_denied")
|
||||
)
|
||||
continue
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.warning("Builtin skill read failed %s: %s", target, exc)
|
||||
responses.append(FileDownloadResponse(path=p, error="file_not_found"))
|
||||
continue
|
||||
responses.append(FileDownloadResponse(path=p, content=content, error=None))
|
||||
return responses
|
||||
|
||||
|
||||
class SearchSpaceSkillsBackend(BackendProtocol):
|
||||
"""Read-only view of search-space-authored skills.
|
||||
|
||||
Wraps a :class:`KBPostgresBackend` and only ever reads under the privileged
|
||||
folder ``/documents/_skills/`` (configurable). The folder is intended to be
|
||||
writable only by search-space admins; this backend never writes.
|
||||
|
||||
The skills middleware expects a layout like::
|
||||
|
||||
/<source_root>/<skill-name>/SKILL.md
|
||||
|
||||
But the KB stores documents like ``/documents/_skills/<name>/SKILL.md``.
|
||||
We expose the inner namespace by remapping each path. When mounted under
|
||||
:class:`CompositeBackend` at prefix ``/skills/space/`` the paths the
|
||||
middleware sees become ``/skills/space/<name>/SKILL.md``; the composite
|
||||
strips ``/skills/space/`` and hands us ``/<name>/SKILL.md``, which we
|
||||
rewrite to ``/documents/_skills/<name>/SKILL.md`` before forwarding to the
|
||||
KB.
|
||||
|
||||
No new database table is needed: the privileged folder convention is
|
||||
enforced server-side outside of this class. We intentionally swallow any
|
||||
write/edit attempts (the base class raises ``NotImplementedError``).
|
||||
"""
|
||||
|
||||
DEFAULT_KB_ROOT: str = "/documents/_skills"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kb_backend: KBPostgresBackend,
|
||||
*,
|
||||
kb_root: str = DEFAULT_KB_ROOT,
|
||||
) -> None:
|
||||
self._kb = kb_backend
|
||||
# Normalize trailing slash off so we can join cleanly.
|
||||
self._kb_root = kb_root.rstrip("/") or "/"
|
||||
|
||||
def _to_kb(self, path: str) -> str:
|
||||
"""Rewrite a virtual path into the underlying KB namespace."""
|
||||
bare = path.lstrip("/")
|
||||
if not bare:
|
||||
return self._kb_root
|
||||
return f"{self._kb_root}/{bare}"
|
||||
|
||||
def _from_kb(self, kb_path: str) -> str:
|
||||
"""Rewrite a KB path back into our virtual namespace."""
|
||||
if not kb_path.startswith(self._kb_root):
|
||||
return kb_path # pragma: no cover - defensive
|
||||
rel = kb_path[len(self._kb_root) :]
|
||||
return rel if rel.startswith("/") else "/" + rel
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
# KBPostgresBackend exposes only the async API meaningfully; the sync
|
||||
# path falls back to ``asyncio.to_thread(...)`` in the base class. We
|
||||
# keep this stub to satisfy abstract resolution; the middleware calls
|
||||
# ``als_info``.
|
||||
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
|
||||
|
||||
async def als_info(self, path: str) -> list[FileInfo]:
|
||||
kb_path = self._to_kb(path)
|
||||
try:
|
||||
infos = await self._kb.als_info(kb_path)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("SearchSpaceSkillsBackend.als_info failed: %s", exc)
|
||||
return []
|
||||
remapped: list[FileInfo] = []
|
||||
for info in infos:
|
||||
kb_p = info.get("path", "")
|
||||
if not kb_p.startswith(self._kb_root):
|
||||
continue
|
||||
remapped.append({**info, "path": self._from_kb(kb_p)})
|
||||
return remapped
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
|
||||
|
||||
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
kb_paths = [self._to_kb(p) for p in paths]
|
||||
responses = await self._kb.adownload_files(kb_paths)
|
||||
# Re-map response paths back to the virtual namespace so the middleware
|
||||
# correlates them to the input list correctly.
|
||||
remapped: list[FileDownloadResponse] = []
|
||||
for original, resp in zip(paths, responses, strict=True):
|
||||
remapped.append(replace(resp, path=original))
|
||||
return remapped
|
||||
|
||||
|
||||
SKILLS_BUILTIN_PREFIX = "/skills/builtin/"
|
||||
SKILLS_SPACE_PREFIX = "/skills/space/"
|
||||
|
||||
|
||||
def build_skills_backend_factory(
|
||||
*,
|
||||
builtin_root: Path | str | None = None,
|
||||
search_space_id: int | None = None,
|
||||
) -> Callable[[ToolRuntime], BackendProtocol]:
|
||||
"""Return a runtime-aware factory for the skills :class:`CompositeBackend`.
|
||||
|
||||
When ``search_space_id`` is provided the composite includes a
|
||||
:class:`SearchSpaceSkillsBackend` route at ``/skills/space/`` over a fresh
|
||||
per-runtime :class:`KBPostgresBackend`, mirroring how
|
||||
:func:`build_backend_resolver` constructs the main filesystem backend.
|
||||
|
||||
When ``search_space_id`` is ``None`` (e.g., desktop-local mode or unit
|
||||
tests) only the bundled :class:`BuiltinSkillsBackend` is exposed.
|
||||
|
||||
Returning a factory rather than a fixed instance is intentional: the
|
||||
underlying KB backend depends on per-call ``ToolRuntime`` state
|
||||
(``staged_dirs``, ``files`` cache, runtime config), so a single shared
|
||||
instance cannot serve multiple concurrent agent runs.
|
||||
"""
|
||||
builtin = BuiltinSkillsBackend(builtin_root)
|
||||
|
||||
if search_space_id is None:
|
||||
|
||||
def _factory_builtin_only(runtime: ToolRuntime) -> BackendProtocol:
|
||||
# Default StateBackend is intentionally inert: any path outside the
|
||||
# ``/skills/builtin/`` route resolves to an empty per-runtime state
|
||||
# so the SkillsMiddleware can iterate sources without raising.
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={SKILLS_BUILTIN_PREFIX: builtin},
|
||||
)
|
||||
|
||||
return _factory_builtin_only
|
||||
|
||||
def _factory_with_space(runtime: ToolRuntime) -> BackendProtocol:
|
||||
# Imported lazily to avoid a hard dependency at module import time:
|
||||
# ``KBPostgresBackend`` pulls in DB models, which are unnecessary for
|
||||
# the unit-tested builtin path.
|
||||
from app.agents.shared.middleware.kb_postgres_backend import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
|
||||
kb = KBPostgresBackend(search_space_id, runtime)
|
||||
space = SearchSpaceSkillsBackend(kb)
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={
|
||||
SKILLS_BUILTIN_PREFIX: builtin,
|
||||
SKILLS_SPACE_PREFIX: space,
|
||||
},
|
||||
)
|
||||
|
||||
return _factory_with_space
|
||||
|
||||
|
||||
def default_skills_sources() -> list[str]:
|
||||
"""Return the canonical source list for SkillsMiddleware (built-in then space)."""
|
||||
return [SKILLS_BUILTIN_PREFIX, SKILLS_SPACE_PREFIX]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SKILLS_BUILTIN_PREFIX",
|
||||
"SKILLS_SPACE_PREFIX",
|
||||
"BuiltinSkillsBackend",
|
||||
"SearchSpaceSkillsBackend",
|
||||
"build_skills_backend_factory",
|
||||
"default_skills_sources",
|
||||
]
|
||||
|
|
@ -34,8 +34,6 @@ from langchain.agents.middleware.types import (
|
|||
from langchain_core.messages import AIMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -120,6 +118,11 @@ class ToolCallNameRepairMiddleware(
|
|||
return call
|
||||
|
||||
# Stage 2 — invalid fallback
|
||||
# Local import avoids a module-load cycle through the frozen single-agent
|
||||
# package (new_chat.__init__ -> chat_deepagent -> middleware shim).
|
||||
# Resolves to app.agents.shared.tools once tools migrate.
|
||||
from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME
|
||||
|
||||
if INVALID_TOOL_NAME in registered:
|
||||
original_args = call.get("args") or {}
|
||||
error_msg = (
|
||||
|
|
@ -23,7 +23,7 @@ the receipt into the parent's ``receipts`` state via the append reducer.
|
|||
|
||||
The KB write path is the one exception: file-tool calls cannot emit a
|
||||
durable receipt because the actual DB writes happen end-of-turn inside
|
||||
:class:`app.agents.new_chat.middleware.kb_persistence.KnowledgeBasePersistenceMiddleware`.
|
||||
:class:`app.agents.shared.middleware.kb_persistence.KnowledgeBasePersistenceMiddleware`.
|
||||
KB tools therefore emit a *provisional* receipt with ``status="pending"``;
|
||||
the persistence middleware flips it to ``"success"`` or ``"failed"``
|
||||
before returning control to the parent.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from app.agents.shared.filesystem_selection import (
|
|||
FilesystemSelection,
|
||||
LocalFilesystemMount,
|
||||
)
|
||||
from app.agents.new_chat.middleware.busy_mutex import (
|
||||
from app.agents.shared.middleware.busy_mutex import (
|
||||
get_cancel_state,
|
||||
is_cancel_requested,
|
||||
manager,
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ from app.agents.shared.llm_config import (
|
|||
load_global_llm_config_by_id,
|
||||
)
|
||||
from app.agents.shared.mention_resolver import resolve_mentions, substitute_in_text
|
||||
from app.agents.new_chat.middleware.busy_mutex import (
|
||||
from app.agents.shared.middleware.busy_mutex import (
|
||||
end_turn,
|
||||
get_cancel_state,
|
||||
is_cancel_requested,
|
||||
)
|
||||
from app.agents.new_chat.middleware.kb_persistence import (
|
||||
from app.agents.shared.middleware.kb_persistence import (
|
||||
commit_staged_filesystem_state,
|
||||
)
|
||||
from app.db import (
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator
|
|||
from typing import Any
|
||||
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.middleware.kb_persistence import (
|
||||
from app.agents.shared.middleware.kb_persistence import (
|
||||
commit_staged_filesystem_state,
|
||||
)
|
||||
from app.services.new_streaming_service import VercelStreamingService
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import time
|
|||
from typing import Any, Literal
|
||||
|
||||
from app.agents.shared.errors import BusyError
|
||||
from app.agents.new_chat.middleware.busy_mutex import (
|
||||
from app.agents.shared.middleware.busy_mutex import (
|
||||
get_cancel_state,
|
||||
is_cancel_requested,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import anyio
|
|||
from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
|
||||
from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection
|
||||
from app.agents.new_chat.middleware.busy_mutex import end_turn
|
||||
from app.agents.shared.middleware.busy_mutex import end_turn
|
||||
from app.config import config as _app_config
|
||||
from app.db import ChatVisibility, async_session_maker
|
||||
from app.observability import otel as ot
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import anyio
|
|||
from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
|
||||
from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection
|
||||
from app.agents.new_chat.middleware.busy_mutex import end_turn
|
||||
from app.agents.shared.middleware.busy_mutex import end_turn
|
||||
from app.config import config as _app_config
|
||||
from app.db import ChatVisibility, async_session_maker
|
||||
from app.observability import otel as ot
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from typing import Literal
|
|||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.new_chat.middleware.busy_mutex import end_turn
|
||||
from app.agents.shared.middleware.busy_mutex import end_turn
|
||||
from app.observability import otel as ot
|
||||
from app.services.auto_model_pin_service import (
|
||||
mark_runtime_cooldown,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue