mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
refactor(agents): move skills/, plugins/, plugin_loader to app/agents/shared (slice 7)
- skills/ (builtin SKILL.md assets) has zero Python importers; it is read by filesystem path only. Moved the dir and restored skills_backends._default_builtin_root() to the clean parent.parent / "skills" / "builtin" form (undoing the transient path from 5c). - plugin_loader.py -> shared (frozen chat_deepagent uses it -> re-export shim). - plugins/ package -> shared (year_substituter rewired to shared.plugin_loader; docstring entry-point example updated to the shared dotted path). No shim needed (only a test imported it). Plugin discovery is via importlib entry points (group "surfsense.plugins"), not dotted-path import, and nothing is registered in pyproject, so the move does not affect runtime discovery.
This commit is contained in:
parent
aab95b9130
commit
13a96851ef
14 changed files with 182 additions and 167 deletions
|
|
@ -16,7 +16,7 @@ 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/``.
|
||||
``app/agents/shared/skills/builtin/``.
|
||||
* :class:`SearchSpaceSkillsBackend` — a thin read-only wrapper over
|
||||
:class:`KBPostgresBackend` that filters notes under the privileged folder
|
||||
``/documents/_skills/``.
|
||||
|
|
@ -59,14 +59,10 @@ _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"``.
|
||||
Located at ``app/agents/shared/skills/builtin/`` relative to this module
|
||||
(this module lives at ``app/agents/shared/middleware/skills_backends.py``).
|
||||
"""
|
||||
agents_dir = Path(__file__).resolve().parent.parent.parent
|
||||
return (agents_dir / "new_chat" / "skills" / "builtin").resolve()
|
||||
return (Path(__file__).resolve().parent.parent / "skills" / "builtin").resolve()
|
||||
|
||||
|
||||
class BuiltinSkillsBackend(BackendProtocol):
|
||||
|
|
|
|||
158
surfsense_backend/app/agents/shared/plugin_loader.py
Normal file
158
surfsense_backend/app/agents/shared/plugin_loader.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""Entry-point based plugin loader for SurfSense agent middleware.
|
||||
|
||||
LangChain's :class:`AgentMiddleware` ABC already covers the practical
|
||||
surface most plugins need (``before_agent`` / ``before_model`` /
|
||||
``wrap_tool_call`` / their async counterparts), so a SurfSense-specific
|
||||
plugin protocol would be redundant. We just need a way to discover and
|
||||
admit third-party middleware safely.
|
||||
|
||||
A plugin is therefore just an installable Python package that registers a
|
||||
factory callable under the ``surfsense.plugins`` entry-point group:
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
# in a plugin package's pyproject.toml
|
||||
[project.entry-points."surfsense.plugins"]
|
||||
year_substituter = "my_plugin:make_middleware"
|
||||
|
||||
The factory has the signature ``Callable[[PluginContext], AgentMiddleware]``.
|
||||
It receives a small, sanitized :class:`PluginContext` with the IDs and the
|
||||
LLM the plugin is allowed to talk to — and **never** raw secrets, DB
|
||||
sessions, or other connectors.
|
||||
|
||||
## Trust model
|
||||
|
||||
Plugins are loaded **only if** their entry-point ``name`` appears in
|
||||
``allowed_plugins`` (admin-controlled, sourced from
|
||||
``global_llm_config.yaml`` or :func:`load_allowed_plugin_names_from_env`).
|
||||
There is **no env-driven auto-load**. A plugin failure is logged and
|
||||
isolated; it does not break agent construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from importlib.metadata import entry_points
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type-only
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from app.db import ChatVisibility
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLUGIN_ENTRY_POINT_GROUP = "surfsense.plugins"
|
||||
|
||||
|
||||
class PluginContext(dict):
|
||||
"""Sanitized DI bag handed to each plugin factory.
|
||||
|
||||
Backed by ``dict`` so plugins can inspect the keys they care about
|
||||
without coupling to a concrete dataclass shape. Required keys:
|
||||
|
||||
* ``search_space_id`` (int)
|
||||
* ``user_id`` (str | None)
|
||||
* ``thread_visibility`` (:class:`app.db.ChatVisibility`)
|
||||
* ``llm`` (:class:`langchain_core.language_models.BaseChatModel`)
|
||||
|
||||
The context **never** carries DB sessions, raw secrets, or other
|
||||
connectors. If a future plugin genuinely needs DB access, that
|
||||
integration goes through a rate-limited service interface, not
|
||||
through this bag.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
search_space_id: int,
|
||||
user_id: str | None,
|
||||
thread_visibility: ChatVisibility,
|
||||
llm: BaseChatModel,
|
||||
) -> PluginContext:
|
||||
return cls(
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
thread_visibility=thread_visibility,
|
||||
llm=llm,
|
||||
)
|
||||
|
||||
|
||||
def load_plugin_middlewares(
|
||||
ctx: PluginContext,
|
||||
allowed_plugin_names: Iterable[str],
|
||||
) -> list[AgentMiddleware]:
|
||||
"""Discover, allowlist-filter, and instantiate plugin middleware.
|
||||
|
||||
For each entry-point in :data:`PLUGIN_ENTRY_POINT_GROUP` whose name is
|
||||
in ``allowed_plugin_names``, load the factory and call it with ``ctx``.
|
||||
The factory's return value must be an :class:`AgentMiddleware` instance;
|
||||
anything else is logged and skipped.
|
||||
|
||||
Errors are isolated — a plugin that raises during ``ep.load()`` or
|
||||
factory invocation is logged at ``ERROR`` and ignored. Agent
|
||||
construction continues with whatever plugins did succeed.
|
||||
"""
|
||||
allowed = {name for name in allowed_plugin_names if name}
|
||||
if not allowed:
|
||||
return []
|
||||
|
||||
out: list[AgentMiddleware] = []
|
||||
try:
|
||||
eps = entry_points(group=PLUGIN_ENTRY_POINT_GROUP)
|
||||
except Exception: # pragma: no cover - defensive (entry_points is robust)
|
||||
logger.exception("Failed to enumerate plugin entry points")
|
||||
return []
|
||||
|
||||
for ep in eps:
|
||||
if ep.name not in allowed:
|
||||
logger.info("Skipping non-allowlisted plugin %s", ep.name)
|
||||
continue
|
||||
try:
|
||||
factory = ep.load()
|
||||
except Exception:
|
||||
logger.exception("Failed to load plugin %s", ep.name)
|
||||
continue
|
||||
try:
|
||||
mw = factory(ctx)
|
||||
except Exception:
|
||||
logger.exception("Plugin %s factory raised", ep.name)
|
||||
continue
|
||||
if not isinstance(mw, AgentMiddleware):
|
||||
logger.warning(
|
||||
"Plugin %s returned %s, expected AgentMiddleware; skipping",
|
||||
ep.name,
|
||||
type(mw).__name__,
|
||||
)
|
||||
continue
|
||||
out.append(mw)
|
||||
logger.info("Loaded plugin %s as %s", ep.name, type(mw).__name__)
|
||||
return out
|
||||
|
||||
|
||||
def load_allowed_plugin_names_from_env() -> set[str]:
|
||||
"""Read ``SURFSENSE_ALLOWED_PLUGINS`` (comma-separated) into a set.
|
||||
|
||||
Provided as a thin convenience for deployments that don't surface plugins
|
||||
through ``global_llm_config.yaml`` yet. Whitespace is stripped and empty
|
||||
entries are dropped.
|
||||
"""
|
||||
raw = os.environ.get("SURFSENSE_ALLOWED_PLUGINS", "").strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {token.strip() for token in raw.split(",") if token.strip()}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PLUGIN_ENTRY_POINT_GROUP",
|
||||
"PluginContext",
|
||||
"load_allowed_plugin_names_from_env",
|
||||
"load_plugin_middlewares",
|
||||
]
|
||||
6
surfsense_backend/app/agents/shared/plugins/__init__.py
Normal file
6
surfsense_backend/app/agents/shared/plugins/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Reference plugins bundled with SurfSense.
|
||||
|
||||
These plugins are intentionally small and demonstrative. They are NOT
|
||||
auto-loaded — they ship as examples that a deployment can opt into via
|
||||
``global_llm_config.yaml`` or ``SURFSENSE_ALLOWED_PLUGINS``.
|
||||
"""
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"""Reference plugin: substitute ``{{year}}`` in tool descriptions.
|
||||
|
||||
Demonstrates the :meth:`AgentMiddleware.awrap_tool_call` hook -- the
|
||||
plugin sees every tool invocation and can rewrite the request *or* the
|
||||
result. This particular plugin is read-only and only transforms the
|
||||
*description* the user might see in error messages (no request
|
||||
mutation).
|
||||
|
||||
The plugin is built as a factory function so the entry-point loader can
|
||||
inject :class:`PluginContext` (containing the agent's LLM, search-space
|
||||
ID, etc.). The factory signature
|
||||
``Callable[[PluginContext], AgentMiddleware]`` is the only contract --
|
||||
SurfSense doesn't define a custom plugin protocol on top of LangChain's
|
||||
:class:`AgentMiddleware`.
|
||||
|
||||
Wire-up in ``pyproject.toml`` (illustrative; the in-repo plugin doesn't
|
||||
need this -- it's already on the import path)::
|
||||
|
||||
[project.entry-points."surfsense.plugins"]
|
||||
year_substituter = "app.agents.shared.plugins.year_substituter:make_middleware"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type-only
|
||||
from langchain.agents.middleware.types import ToolCallRequest
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agents.shared.plugin_loader import PluginContext
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _YearSubstituterMiddleware(AgentMiddleware):
|
||||
"""Replace ``{{year}}`` in the result text with the current UTC year."""
|
||||
|
||||
tools = ()
|
||||
|
||||
def __init__(self, year: int | None = None) -> None:
|
||||
super().__init__()
|
||||
self._year = str(year if year is not None else datetime.now(UTC).year)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
||||
) -> ToolMessage | Command[Any]:
|
||||
result = await handler(request)
|
||||
try:
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
if (
|
||||
isinstance(result, ToolMessage)
|
||||
and isinstance(result.content, str)
|
||||
and "{{year}}" in result.content
|
||||
):
|
||||
new_text = result.content.replace("{{year}}", self._year)
|
||||
result = ToolMessage(
|
||||
content=new_text,
|
||||
tool_call_id=result.tool_call_id,
|
||||
id=result.id,
|
||||
name=result.name,
|
||||
status=result.status,
|
||||
artifact=result.artifact,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
logger.exception("year_substituter plugin failed; passing original result")
|
||||
return result
|
||||
|
||||
|
||||
def make_middleware(ctx: PluginContext) -> AgentMiddleware:
|
||||
"""Plugin factory used by :func:`load_plugin_middlewares`."""
|
||||
# Plugin is intentionally small so it has no state to threading-protect
|
||||
# and ignores ``ctx`` beyond demonstrating that the loader passes it in.
|
||||
_ = ctx
|
||||
return _YearSubstituterMiddleware()
|
||||
|
||||
|
||||
__all__ = ["make_middleware"]
|
||||
7
surfsense_backend/app/agents/shared/skills/__init__.py
Normal file
7
surfsense_backend/app/agents/shared/skills/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""SurfSense built-in agent skills (Anthropic Skills format).
|
||||
|
||||
Each subdirectory corresponds to one skill and contains a ``SKILL.md`` file
|
||||
with YAML frontmatter (name, description, allowed_tools) plus markdown
|
||||
instructions. The :class:`BuiltinSkillsBackend` exposes them to the
|
||||
deepagents :class:`SkillsMiddleware`.
|
||||
"""
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: email-drafting
|
||||
description: Draft an email matching the user's voice, with structured intent and CTA
|
||||
---
|
||||
|
||||
# Email drafting
|
||||
|
||||
## When to use this skill
|
||||
"Draft an email to ...", "reply to this thread", "write a follow-up to X". Plain "summarize the email" is **not** in scope — that's a comprehension task.
|
||||
|
||||
## Voice
|
||||
Search the KB for prior emails from the user to similar audiences (same recipient, same topic class). Mirror tone, opening style, sign-off, and length distribution. If there is no precedent, default to: warm, direct, no filler, short paragraphs, one clear ask.
|
||||
|
||||
## Required structure
|
||||
Every draft includes, in this order:
|
||||
|
||||
1. **Subject line** — concrete, ≤ 8 words, no clickbait, no `Re:` unless replying.
|
||||
2. **Opening (1 sentence)** — context the recipient already shares; never restate what they wrote unless the thread is long.
|
||||
3. **Body** — the actual point in one short paragraph. Bullets only if there are >3 discrete items.
|
||||
4. **Single explicit CTA** — what you want the recipient to do, with a soft deadline if relevant.
|
||||
5. **Sign-off** — match the user's prior closing style.
|
||||
|
||||
## Always offer alternatives
|
||||
End your message with: "Want me to make it shorter, more formal, or add a different angle?" — give the user one obvious next step.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
name: kb-research
|
||||
description: Structured approach to finding and synthesizing information from the user's knowledge base
|
||||
allowed-tools: scrape_webpage, read_file, ls_tree, grep, web_search
|
||||
---
|
||||
|
||||
# Knowledge-base research
|
||||
|
||||
## When to use this skill
|
||||
- The user asks "find/look up/research" something specifically inside their knowledge base.
|
||||
- The user references documents, notes, repos, or connector data they expect to exist already.
|
||||
- A multi-document synthesis is required (e.g., "summarize what we've discussed about X across all my notes").
|
||||
|
||||
## Plan
|
||||
1. Decompose the user's question into 2-4 specific, citation-worthy sub-questions.
|
||||
2. For each sub-question, run **one** targeted KB search (focused on terms the user would have written, not synonyms). Open the most relevant 2-3 documents fully via `read_file` if their excerpts are too short.
|
||||
3. Use `grep` to find supporting passages in long files instead of re-reading them end to end.
|
||||
4. Cite every claim with `[citation:chunk_id]` exactly as the chunk tag specifies.
|
||||
|
||||
## What good output looks like
|
||||
- Short paragraphs with inline citations.
|
||||
- Quoted phrases when wording matters.
|
||||
- An explicit "Not found in your knowledge base" callout when a sub-question has no support — never fabricate.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
name: meeting-prep
|
||||
description: Pull together briefing materials before a scheduled meeting
|
||||
allowed-tools: web_search, scrape_webpage, read_file
|
||||
---
|
||||
|
||||
# Meeting preparation
|
||||
|
||||
## When to use this skill
|
||||
The user mentions an upcoming meeting, call, or interview and asks you to "prep", "brief me", "pull background", or "what do I need to know about X before tomorrow".
|
||||
|
||||
## Output structure
|
||||
Always produce these sections (omit any with no signal — don't pad):
|
||||
|
||||
1. **Attendees & context** — who's in the room, their roles, what they care about. Pull from KB notes about prior interactions; supplement with public profile facts via `web_search` when names or companies are unfamiliar.
|
||||
2. **Open threads** — outstanding action items, unresolved decisions, last-mentioned blockers from prior conversation history.
|
||||
3. **Recent moves** — within the last 30 days: relevant launches, hires, news. Cite KB chunks when present, otherwise external sources.
|
||||
4. **Suggested questions** — 3-5 questions the user could ask, tailored to the open threads and the attendees' likely priorities.
|
||||
|
||||
## Source ordering
|
||||
- Always check the user's KB **first** for prior meeting notes, internal docs, or Slack threads about these attendees.
|
||||
- Only fall back to `web_search` for *publicly verifiable* facts — never to fabricate a participant's preferences or relationships.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
name: report-writing
|
||||
description: How to scope, draft, and revise a Markdown report artifact via generate_report
|
||||
allowed-tools: generate_report, read_file
|
||||
---
|
||||
|
||||
# Report writing
|
||||
|
||||
## When to use this skill
|
||||
The user explicitly requests a deliverable: "write a report on …", "draft a memo", "produce a brief", "expand the previous report". A creation or modification verb pointed at an artifact is required (see `generate_report`'s when-to-call rules).
|
||||
|
||||
## Decision flow
|
||||
1. **Source strategy.** Decide which `source_strategy` fits:
|
||||
- `conversation` — substantive Q&A on the topic already in chat.
|
||||
- `kb_search` — fresh topic; supply 1–5 precise `search_queries`.
|
||||
- `auto` — partial conversation context; let the tool fall back.
|
||||
- `provided` — verbatim source text only.
|
||||
2. **Style.** Default to `report_style="detailed"` unless the user explicitly asks for "brief", "one page", "500 words".
|
||||
3. **Revisions.** When modifying an existing report from this conversation, set `parent_report_id` and put the change list in `user_instructions` ("add carbon-capture section", "tighten conclusion").
|
||||
4. **Never paste the report back into chat** after `generate_report` returns — confirm and let the artifact card render itself.
|
||||
|
||||
## Hooks for KB-only mode
|
||||
If `kb_search`/`auto` returns no results, do **not** silently switch to general knowledge. Surface the gap in your confirmation message.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
name: slack-summary
|
||||
description: Distill a Slack channel or thread into actionable summary
|
||||
---
|
||||
|
||||
# Slack summarization
|
||||
|
||||
## When to use this skill
|
||||
The user asks to summarize Slack ("what happened in #eng-platform this week", "what did Alice say about the launch", "catch me up on the design channel").
|
||||
|
||||
## Required inputs
|
||||
Confirm before searching:
|
||||
- **Which channel(s) or thread(s)?** Don't guess if ambiguous.
|
||||
- **What time window?** Default to the last 7 days when not specified, but say so.
|
||||
|
||||
## Output shape
|
||||
Produce three concise sections:
|
||||
1. **Key decisions** — explicit choices that were made, with the deciding message cited.
|
||||
2. **Open questions** — things asked but not answered, with the asking message cited.
|
||||
3. **Action items** — `@mention` who owes what by when, *only if explicitly stated*. Don't invent assignees.
|
||||
|
||||
## What not to do
|
||||
- Never produce a chronological play-by-play of every message — distill.
|
||||
- Never quote private messages without flagging them as such.
|
||||
- If the channel was empty in the time window, say so — don't fabricate filler.
|
||||
Loading…
Add table
Add a link
Reference in a new issue