mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
Merge commit 'fa7075fde6' into dev
This commit is contained in:
commit
b477d33cba
1421 changed files with 59474 additions and 27447 deletions
|
|
@ -55,11 +55,6 @@ WHATSAPP_WEBHOOK_VERIFY_TOKEN=
|
|||
WHATSAPP_WEBHOOK_APP_SECRET=
|
||||
WHATSAPP_BRIDGE_URL=http://whatsapp-bridge:9929
|
||||
|
||||
# Platform Web Search (SearXNG)
|
||||
# Set this to enable built-in web search. Docker Compose sets it automatically.
|
||||
# Only uncomment if running the backend outside Docker (e.g. uvicorn on host).
|
||||
# SEARXNG_DEFAULT_HOST=http://localhost:8888
|
||||
|
||||
# Periodic task interval
|
||||
# # Run every minute (default)
|
||||
# SCHEDULE_CHECKER_INTERVAL=1m
|
||||
|
|
@ -257,6 +252,41 @@ DEFAULT_CREDIT_MICROS_BALANCE=5000000
|
|||
ETL_CREDIT_BILLING_ENABLED=FALSE
|
||||
MICROS_PER_PAGE=1000
|
||||
|
||||
# Debit the credit wallet per *successful* web crawl. Default FALSE keeps
|
||||
# crawling effectively free for self-hosted/OSS installs; hosted sets TRUE.
|
||||
# Price is fully config-driven (the only source of truth, no hardcoded rate):
|
||||
# WEB_CRAWL_MICROS_PER_SUCCESS = round(USD_per_1000_crawls * 1_000)
|
||||
# 2000 == $2 / 1000 crawls (default) | 1000 == $1/1000 | 500 == $0.50/1000
|
||||
# Retune anytime with just an env change + restart (no code/migration).
|
||||
# WEB_CRAWL_CREDIT_BILLING_ENABLED=FALSE
|
||||
# WEB_CRAWL_MICROS_PER_SUCCESS=2000
|
||||
|
||||
# Phase 3d: bill captcha solves as a SEPARATE per-attempt unit (the solver
|
||||
# charges per attempt regardless of crawl success). Independent of the crawl
|
||||
# flag above. Price config-driven (no hardcoded rate):
|
||||
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE = round(USD_per_1000_solves * 1_000)
|
||||
# 3000 == $3 / 1000 solves (default) | 5000 == $5/1000
|
||||
# Set with margin over your solver vendor's per-attempt price.
|
||||
# WEB_CRAWL_CAPTCHA_BILLING_ENABLED=FALSE
|
||||
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE=3000
|
||||
|
||||
# Debit the credit wallet per *item returned* by the platform-native scrapers
|
||||
# (Reddit, Google Search, Google Maps, YouTube). Default FALSE keeps scraping
|
||||
# effectively free for self-hosted/OSS installs; hosted deployments set TRUE.
|
||||
# Each rate is micro-USD per item, fully config-driven (no hardcoded rate):
|
||||
# <KEY> = round(USD_per_1000_items * 1_000)
|
||||
# 3500 == $3.50/1000 | 5000 == $5/1000 | 2000 == $2/1000
|
||||
# Defaults sit at/above Apify's first-party actor rates (Jul 2026); justified
|
||||
# because we charge no subscription tiers, no per-run start fees, and no
|
||||
# separate proxy/compute/storage billing. Retune anytime via env + restart.
|
||||
# PLATFORM_SCRAPE_BILLING_ENABLED=FALSE
|
||||
# REDDIT_SCRAPE_MICROS_PER_ITEM=3500
|
||||
# GOOGLE_SEARCH_MICROS_PER_SERP=5500
|
||||
# GOOGLE_MAPS_MICROS_PER_PLACE=3500
|
||||
# GOOGLE_MAPS_MICROS_PER_REVIEW=1500
|
||||
# YOUTUBE_MICROS_PER_VIDEO=2500
|
||||
# YOUTUBE_MICROS_PER_COMMENT=1500
|
||||
|
||||
# Low-balance warning threshold (micro-USD), surfaced to the UI. Default $0.50.
|
||||
CREDIT_LOW_BALANCE_WARNING_MICROS=500000
|
||||
|
||||
|
|
@ -313,17 +343,57 @@ TURNSTILE_SECRET_KEY=
|
|||
|
||||
|
||||
# Proxy provider selection. Selects a ProxyProvider implementation registered in
|
||||
# app/utils/proxy/registry.py. Default: "anonymous_proxies". Add new vendors there.
|
||||
# PROXY_PROVIDER=anonymous_proxies
|
||||
# app/utils/proxy/registry.py. Default: "custom". Options: "custom", "dataimpulse".
|
||||
# PROXY_PROVIDER=custom
|
||||
|
||||
# Residential Proxy Configuration (anonymous-proxies.net)
|
||||
# Used for web crawling, link previews, and YouTube transcript fetching to avoid IP bans.
|
||||
# Consumed by the "anonymous_proxies" provider. Leave commented out to disable proxying.
|
||||
# RESIDENTIAL_PROXY_USERNAME=your_proxy_username
|
||||
# RESIDENTIAL_PROXY_PASSWORD=your_proxy_password
|
||||
# RESIDENTIAL_PROXY_HOSTNAME=rotating.dnsproxifier.com:31230
|
||||
# RESIDENTIAL_PROXY_LOCATION=
|
||||
# RESIDENTIAL_PROXY_TYPE=1
|
||||
# Proxy endpoint(s), shared across providers — PROXY_PROVIDER picks the behavior,
|
||||
# not a different env name. Used for web crawling, link previews, and YouTube
|
||||
# transcript fetching to avoid IP bans. Leave unset to disable proxying.
|
||||
#
|
||||
# PROXY_URL: a single full http://user:pass@host:port endpoint (used by every
|
||||
# provider). For "dataimpulse", country is a "__cr.<country>" username suffix the
|
||||
# provider parses for geoip-match, e.g.:
|
||||
# PROXY_URL=http://your_token__cr.us:your_password@gw.dataimpulse.com:823
|
||||
# PROXY_URLS: a comma-separated pool the "custom" provider rotates client-side
|
||||
# (cyclic); server-side-rotating gateways ignore it.
|
||||
# PROXY_URL=http://user:pass@host:port
|
||||
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2:port
|
||||
|
||||
# =====================================================================
|
||||
# Captcha solving (Phase 3d) — LAST-resort bypass tier via captchatools.
|
||||
# Only fires on the stealth browser tier when a sitekey is detected AND
|
||||
# CAPTCHA_SOLVING_ENABLED=TRUE. Cloudflare Turnstile is already solved free
|
||||
# in-framework (no config needed). Off by default => zero attempts, zero cost.
|
||||
# NOTE: automated solving may violate a target site's ToS — opt-in, public
|
||||
# data only (no logged-in bypass). captchatools is itself the vendor registry.
|
||||
# CAPTCHA_SOLVING_ENABLED=FALSE
|
||||
# solving_site: capmonster | 2captcha | anticaptcha | capsolver | captchaai
|
||||
# CAPTCHA_SOLVER_PROVIDER=capsolver
|
||||
# CAPTCHA_SOLVER_API_KEY=
|
||||
# Per-URL solve cap (bounds solver spend on a hostile page).
|
||||
# CAPTCHA_MAX_ATTEMPTS_PER_URL=1
|
||||
# CAPTCHA_SOLVE_TIMEOUT_S=120
|
||||
# Default type when detection is ambiguous: v2 | v3 | hcaptcha
|
||||
# CAPTCHA_TYPE_DEFAULT=v2
|
||||
# reCAPTCHA v3 tuning (only used for v3 challenges).
|
||||
# CAPTCHA_V3_MIN_SCORE=0.7
|
||||
# CAPTCHA_V3_ACTION=verify
|
||||
|
||||
# =====================================================================
|
||||
# Stealth hardening (Phase 3e, Slice A) — runtime/config-level levers on the
|
||||
# stealth browser tier. Consumed by app/proprietary/web_crawler/stealth.py.
|
||||
# Defaults add no crawl-speed regression and preserve today's behavior.
|
||||
# Match browser locale/timezone to the proxy provider's exit region (no exit-IP lookup).
|
||||
# CRAWL_GEOIP_MATCH_ENABLED=FALSE
|
||||
# WebRTC respects the proxy (anti local-IP leak). Cheap + safe.
|
||||
# CRAWL_BLOCK_WEBRTC=TRUE
|
||||
# Random canvas noise (an unstable canvas hash is itself a tell) — opt-in only.
|
||||
# CRAWL_HIDE_CANVAS=FALSE
|
||||
# Send a Google referer so the first hit looks like organic arrival.
|
||||
# CRAWL_GOOGLE_SEARCH_REFERER=TRUE
|
||||
# Route DNS via Cloudflare DoH (anti DNS-leak). Adds a DNS round-trip => off by
|
||||
# default to avoid any speed regression; enable for leak-safety-first setups.
|
||||
# CRAWL_DNS_OVER_HTTPS=FALSE
|
||||
|
||||
# File Parser Service
|
||||
ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
|
||||
|
|
@ -472,7 +542,7 @@ LANGSMITH_PROJECT=surfsense
|
|||
# -----------------------------------------------------------------------------
|
||||
# Connector discovery TTL cache (Phase 1.4 perf optimization)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Caches the per-search-space "available connectors" + "available document
|
||||
# Caches the per-workspace "available connectors" + "available document
|
||||
# types" lookups that ``create_surfsense_deep_agent`` hits on every turn.
|
||||
# ORM event listeners auto-invalidate on connector / document inserts,
|
||||
# updates and deletes — the TTL only bounds staleness for bulk-import
|
||||
|
|
@ -512,8 +582,8 @@ LANGSMITH_PROJECT=surfsense
|
|||
# Per-workspace spawn-paused kill switch — set via Redis at runtime, not
|
||||
# this env var. The env var below only disables the check itself (useful
|
||||
# for local dev without Redis). To pause a workspace in production:
|
||||
# redis-cli SET surfsense:spawn_paused:<search_space_id> 1 EX 600
|
||||
# redis-cli DEL surfsense:spawn_paused:<search_space_id>
|
||||
# redis-cli SET surfsense:spawn_paused:<workspace_id> 1 EX 600
|
||||
# redis-cli DEL surfsense:spawn_paused:<workspace_id>
|
||||
# The check is fail-open: a Redis blip never blocks ``task(...)``.
|
||||
# SURFSENSE_TASK_SPAWN_PAUSED_DISABLED=false
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libxrender1 \
|
||||
dos2unix \
|
||||
git \
|
||||
# ── Phase 3e stealth hardening ──────────────────────────────────────────
|
||||
# Xvfb: virtual framebuffer so the stealth browser can run headful
|
||||
# (headless=False) without a real display — many WAFs flag headless Chromium.
|
||||
# Gated at runtime by CRAWL_HEADED_XVFB_ENABLED (Slice B); installed now so
|
||||
# the image is ready. Real font packages make canvas/emoji/font-enumeration
|
||||
# fingerprints resemble a real desktop (set proven against Kasada/Akamai).
|
||||
xvfb \
|
||||
fonts-noto-color-emoji \
|
||||
fonts-unifont \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-wqy-zenhei \
|
||||
fonts-tlwg-loma-otf \
|
||||
fonts-dejavu \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN which ffmpeg && ffmpeg -version
|
||||
|
|
@ -109,7 +123,7 @@ RUN --mount=type=secret,id=HF_TOKEN \
|
|||
HF_TOKEN="$(cat /run/secrets/HF_TOKEN 2>/dev/null || true)" \
|
||||
python -c "from chonkie import AutoEmbeddings; AutoEmbeddings.get_embeddings('${EMBEDDING_MODEL}')"
|
||||
|
||||
# Install Scrapling's browser engines (patchright Chromium + Camoufox).
|
||||
# Install Scrapling's browser engines (patchright Chromium).
|
||||
# Scrapling pulls playwright/patchright via the `fetchers` extra; `scrapling install`
|
||||
# downloads the matching browser binaries used by DynamicFetcher/StealthyFetcher.
|
||||
RUN scrapling install
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
|
@ -40,6 +42,153 @@ target_metadata = Base.metadata
|
|||
MIGRATION_ADVISORY_LOCK_NAMESPACE = "surfsense"
|
||||
MIGRATION_ADVISORY_LOCK_NAME = "alembic_migrations"
|
||||
|
||||
# Migration 170 renamed searchspaces -> workspaces, so a ``workspaces`` table
|
||||
# can only exist once the schema is at revision >= 170. If it exists while the
|
||||
# recorded revision is missing or still pre-170, the schema did not come from
|
||||
# this migration history at all -- it was created by the startup bootstrap
|
||||
# (``Base.metadata.create_all`` in ``app.db.create_db_and_tables``), which
|
||||
# always builds the *current* model shape. Replaying history against such a
|
||||
# schema fails (e.g. migration 5's ``ALTER COLUMN ... TYPE`` is rejected
|
||||
# because the column already sits in zero_publication's column list), so the
|
||||
# schema is adopted by stamping head instead.
|
||||
BOOTSTRAP_MARKER_TABLE = "workspaces"
|
||||
RENAME_REVISION = "170"
|
||||
|
||||
|
||||
def _stamp_head(connection: Connection, script: ScriptDirectory) -> None:
|
||||
context.get_context().stamp(script, script.get_current_head())
|
||||
if connection.in_transaction():
|
||||
# The outer begin_transaction() is a no-op under
|
||||
# transaction_per_migration, so commit explicitly.
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _fast_forward_fresh_db(connection: Connection) -> bool:
|
||||
"""Build a fresh (empty) DB at head via create_all instead of replaying.
|
||||
|
||||
Historical migrations were written against the pre-workspace-rename
|
||||
schema (``searchspaces``, ``search_space_id``), while migration 0's
|
||||
``create_all`` builds the *current* models -- so replaying the chain on a
|
||||
fresh DB crashes as soon as a migration touches a renamed object (first
|
||||
at migration 18). A fresh DB needs no history: create the head-shape
|
||||
schema directly, mirror migration 0's indexes, create the Zero
|
||||
publication, and stamp head. Replay remains only for legacy DBs that
|
||||
genuinely contain the old objects.
|
||||
|
||||
ponytail: seed-data migrations (114/128 default prompts) are skipped on
|
||||
this path, same as always for create_all-bootstrapped DBs; the app copes
|
||||
with missing seeds. If seeds ever become mandatory, add a runtime seeding
|
||||
step rather than resurrecting the replay.
|
||||
"""
|
||||
for table in ("documents", "searchspaces", BOOTSTRAP_MARKER_TABLE):
|
||||
if connection.execute(sa.text("SELECT to_regclass(:t)"), {"t": table}).scalar():
|
||||
return False
|
||||
if connection.execute(sa.text("SELECT to_regclass('alembic_version')")).scalar():
|
||||
current = connection.execute(
|
||||
sa.text("SELECT version_num FROM alembic_version")
|
||||
).scalar()
|
||||
if current:
|
||||
return False
|
||||
|
||||
logging.getLogger("alembic.env").info(
|
||||
"Fresh database detected: creating head-shape schema via create_all "
|
||||
"and stamping head instead of replaying migration history."
|
||||
)
|
||||
connection.execute(sa.text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
connection.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
Base.metadata.create_all(bind=connection)
|
||||
# Same core indexes migration 0 created (runtime setup_indexes() adds the
|
||||
# rest concurrently on app boot).
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS document_vector_index ON documents "
|
||||
"USING hnsw (embedding public.vector_cosine_ops)"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS document_search_index ON documents "
|
||||
"USING gin (to_tsvector('english', content))"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS chucks_vector_index ON chunks "
|
||||
"USING hnsw (embedding public.vector_cosine_ops)"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS chucks_search_index ON chunks "
|
||||
"USING gin (to_tsvector('english', content))"
|
||||
)
|
||||
)
|
||||
|
||||
from app.zero_publication import ensure_publication
|
||||
|
||||
ensure_publication(connection)
|
||||
|
||||
_stamp_head(connection, ScriptDirectory.from_config(config))
|
||||
return True
|
||||
|
||||
|
||||
def _adopt_bootstrapped_schema(connection: Connection) -> bool:
|
||||
"""Stamp head instead of replaying history on a create_all-created DB.
|
||||
|
||||
Returns True when the schema was adopted (migrations must then be
|
||||
skipped for this run).
|
||||
|
||||
ponytail: assumes the bootstrapped schema matches the checked-out models
|
||||
(true whenever the backend booted on this checkout, since create_all runs
|
||||
on every startup). If the checkout moved ahead without a backend boot,
|
||||
column-level drift from the skipped migrations is possible; the upgrade
|
||||
path is re-bootstrapping (boot the backend once) before stamping.
|
||||
"""
|
||||
marker = connection.execute(
|
||||
sa.text("SELECT to_regclass(:t)"), {"t": BOOTSTRAP_MARKER_TABLE}
|
||||
).scalar()
|
||||
if marker is None:
|
||||
return False
|
||||
|
||||
# Guard against a legacy-shape DB that merely had missing tables filled in
|
||||
# by a later create_all: adoption requires the core tables to be in the
|
||||
# current (post-rename) shape too, not just the marker table to exist.
|
||||
documents_renamed = connection.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() "
|
||||
"AND table_name = 'documents' AND column_name = 'workspace_id'"
|
||||
)
|
||||
).scalar()
|
||||
if not documents_renamed:
|
||||
return False
|
||||
|
||||
current = None
|
||||
if connection.execute(sa.text("SELECT to_regclass('alembic_version')")).scalar():
|
||||
current = connection.execute(
|
||||
sa.text("SELECT version_num FROM alembic_version")
|
||||
).scalar()
|
||||
|
||||
script = ScriptDirectory.from_config(config)
|
||||
pre_rename_revisions = {
|
||||
rev.revision for rev in script.iterate_revisions(RENAME_REVISION, "base")
|
||||
} - {RENAME_REVISION}
|
||||
if current is not None and current not in pre_rename_revisions:
|
||||
# Genuinely migration-managed at >= 170; run migrations normally.
|
||||
return False
|
||||
|
||||
logging.getLogger("alembic.env").info(
|
||||
"Adopting bootstrap-created schema (%r exists, recorded revision %r "
|
||||
"predates the workspace rename): stamping %s instead of replaying "
|
||||
"migration history.",
|
||||
BOOTSTRAP_MARKER_TABLE,
|
||||
current,
|
||||
script.get_current_head(),
|
||||
)
|
||||
_stamp_head(connection, script)
|
||||
return True
|
||||
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
|
|
@ -86,8 +235,11 @@ def do_run_migrations(connection: Connection) -> None:
|
|||
lock_params,
|
||||
)
|
||||
try:
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
if not _fast_forward_fresh_db(connection) and not _adopt_bootstrapped_schema(
|
||||
connection
|
||||
):
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
finally:
|
||||
connection.execute(
|
||||
sa.text("SELECT pg_advisory_unlock(hashtext(:namespace), hashtext(:name))"),
|
||||
|
|
|
|||
|
|
@ -50,9 +50,7 @@ def upgrade() -> None:
|
|||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(64)"
|
||||
)
|
||||
op.execute("ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(64)")
|
||||
op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS is_revoked")
|
||||
|
||||
|
||||
|
|
@ -63,14 +61,19 @@ def downgrade() -> None:
|
|||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE refresh_tokens
|
||||
SET is_revoked = TRUE
|
||||
WHERE revoked_at IS NOT NULL
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'refresh_tokens'
|
||||
AND column_name = 'revoked_at'
|
||||
) THEN
|
||||
UPDATE refresh_tokens SET is_revoked = TRUE WHERE revoked_at IS NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
op.execute("ALTER TABLE refresh_tokens ALTER COLUMN is_revoked DROP DEFAULT")
|
||||
op.execute(
|
||||
"ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(256)"
|
||||
)
|
||||
op.execute("ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(256)")
|
||||
op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS absolute_expiry")
|
||||
op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS revoked_at")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,512 @@
|
|||
"""rename searchspace schema to workspace
|
||||
|
||||
Renames the SearchSpace tables/columns/constraints/indexes/sequences to the
|
||||
workspace_* names the ORM already declares, and reconciles the Zero publication
|
||||
to match.
|
||||
|
||||
Revision ID: 170
|
||||
Revises: 169
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
from app.zero_publication import apply_publication
|
||||
|
||||
revision: str = "170"
|
||||
down_revision: str | None = "169"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PUBLICATION_NAME = "zero_publication"
|
||||
|
||||
# Tables whose published column list references the renamed column. Their
|
||||
# column-list dependency must be removed before RENAME COLUMN is permitted,
|
||||
# then re-added by apply_publication with the new column name.
|
||||
PUBLICATION_COLUMN_LIST_TABLES = [
|
||||
"documents",
|
||||
"automations",
|
||||
"new_chat_threads",
|
||||
"podcasts",
|
||||
]
|
||||
|
||||
# (old_table, new_table)
|
||||
TABLE_RENAMES: list[tuple[str, str]] = [
|
||||
("searchspaces", "workspaces"),
|
||||
("search_space_roles", "workspace_roles"),
|
||||
("search_space_memberships", "workspace_memberships"),
|
||||
("search_space_invites", "workspace_invites"),
|
||||
]
|
||||
|
||||
# (table, old_column, new_column) -- table names are POST-table-rename.
|
||||
COLUMN_RENAMES: list[tuple[str, str, str]] = [
|
||||
("agent_action_log", "search_space_id", "workspace_id"),
|
||||
("agent_permission_rules", "search_space_id", "workspace_id"),
|
||||
("automations", "search_space_id", "workspace_id"),
|
||||
("connections", "search_space_id", "workspace_id"),
|
||||
("document_files", "search_space_id", "workspace_id"),
|
||||
("document_revisions", "search_space_id", "workspace_id"),
|
||||
("documents", "search_space_id", "workspace_id"),
|
||||
("external_chat_bindings", "search_space_id", "workspace_id"),
|
||||
("folder_revisions", "search_space_id", "workspace_id"),
|
||||
("folders", "search_space_id", "workspace_id"),
|
||||
("image_generations", "search_space_id", "workspace_id"),
|
||||
("logs", "search_space_id", "workspace_id"),
|
||||
("new_chat_threads", "search_space_id", "workspace_id"),
|
||||
("notifications", "search_space_id", "workspace_id"),
|
||||
("podcasts", "search_space_id", "workspace_id"),
|
||||
("prompts", "search_space_id", "workspace_id"),
|
||||
("reports", "search_space_id", "workspace_id"),
|
||||
("search_source_connectors", "search_space_id", "workspace_id"),
|
||||
("token_usage", "search_space_id", "workspace_id"),
|
||||
("video_presentations", "search_space_id", "workspace_id"),
|
||||
("external_chat_accounts", "owner_search_space_id", "owner_workspace_id"),
|
||||
("workspace_roles", "search_space_id", "workspace_id"),
|
||||
("workspace_memberships", "search_space_id", "workspace_id"),
|
||||
("workspace_invites", "search_space_id", "workspace_id"),
|
||||
]
|
||||
|
||||
# (table, old_constraint, new_constraint) -- table names are POST-table-rename.
|
||||
CONSTRAINT_RENAMES: list[tuple[str, str, str]] = [
|
||||
(
|
||||
"agent_action_log",
|
||||
"agent_action_log_search_space_id_fkey",
|
||||
"agent_action_log_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"agent_permission_rules",
|
||||
"agent_permission_rules_search_space_id_fkey",
|
||||
"agent_permission_rules_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"automations",
|
||||
"automations_search_space_id_fkey",
|
||||
"automations_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"connections",
|
||||
"connections_search_space_id_fkey",
|
||||
"connections_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"document_files",
|
||||
"document_files_search_space_id_fkey",
|
||||
"document_files_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"document_revisions",
|
||||
"document_revisions_search_space_id_fkey",
|
||||
"document_revisions_workspace_id_fkey",
|
||||
),
|
||||
("documents", "documents_search_space_id_fkey", "documents_workspace_id_fkey"),
|
||||
(
|
||||
"external_chat_accounts",
|
||||
"external_chat_accounts_owner_search_space_id_fkey",
|
||||
"external_chat_accounts_owner_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"external_chat_bindings",
|
||||
"external_chat_bindings_search_space_id_fkey",
|
||||
"external_chat_bindings_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"folder_revisions",
|
||||
"folder_revisions_search_space_id_fkey",
|
||||
"folder_revisions_workspace_id_fkey",
|
||||
),
|
||||
("folders", "folders_search_space_id_fkey", "folders_workspace_id_fkey"),
|
||||
(
|
||||
"image_generations",
|
||||
"image_generations_search_space_id_fkey",
|
||||
"image_generations_workspace_id_fkey",
|
||||
),
|
||||
("logs", "logs_search_space_id_fkey", "logs_workspace_id_fkey"),
|
||||
(
|
||||
"new_chat_threads",
|
||||
"new_chat_threads_search_space_id_fkey",
|
||||
"new_chat_threads_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"notifications",
|
||||
"notifications_search_space_id_fkey",
|
||||
"notifications_workspace_id_fkey",
|
||||
),
|
||||
("podcasts", "podcasts_search_space_id_fkey", "podcasts_workspace_id_fkey"),
|
||||
("prompts", "prompts_search_space_id_fkey", "prompts_workspace_id_fkey"),
|
||||
("reports", "reports_search_space_id_fkey", "reports_workspace_id_fkey"),
|
||||
(
|
||||
"search_source_connectors",
|
||||
"search_source_connectors_search_space_id_fkey",
|
||||
"search_source_connectors_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"search_source_connectors",
|
||||
"uq_searchspace_user_connector_type_name",
|
||||
"uq_workspace_user_connector_type_name",
|
||||
),
|
||||
(
|
||||
"token_usage",
|
||||
"token_usage_search_space_id_fkey",
|
||||
"token_usage_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"video_presentations",
|
||||
"video_presentations_search_space_id_fkey",
|
||||
"video_presentations_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_invites",
|
||||
"search_space_invites_created_by_id_fkey",
|
||||
"workspace_invites_created_by_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_invites",
|
||||
"search_space_invites_pkey",
|
||||
"workspace_invites_pkey",
|
||||
),
|
||||
(
|
||||
"workspace_invites",
|
||||
"search_space_invites_role_id_fkey",
|
||||
"workspace_invites_role_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_invites",
|
||||
"search_space_invites_search_space_id_fkey",
|
||||
"workspace_invites_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_memberships",
|
||||
"search_space_memberships_invited_by_invite_id_fkey",
|
||||
"workspace_memberships_invited_by_invite_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_memberships",
|
||||
"search_space_memberships_pkey",
|
||||
"workspace_memberships_pkey",
|
||||
),
|
||||
(
|
||||
"workspace_memberships",
|
||||
"search_space_memberships_role_id_fkey",
|
||||
"workspace_memberships_role_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_memberships",
|
||||
"search_space_memberships_search_space_id_fkey",
|
||||
"workspace_memberships_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_memberships",
|
||||
"search_space_memberships_user_id_fkey",
|
||||
"workspace_memberships_user_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_memberships",
|
||||
"uq_user_searchspace_membership",
|
||||
"uq_user_workspace_membership",
|
||||
),
|
||||
(
|
||||
"workspace_roles",
|
||||
"search_space_roles_pkey",
|
||||
"workspace_roles_pkey",
|
||||
),
|
||||
(
|
||||
"workspace_roles",
|
||||
"search_space_roles_search_space_id_fkey",
|
||||
"workspace_roles_workspace_id_fkey",
|
||||
),
|
||||
(
|
||||
"workspace_roles",
|
||||
"uq_searchspace_role_name",
|
||||
"uq_workspace_role_name",
|
||||
),
|
||||
("workspaces", "searchspaces_pkey", "workspaces_pkey"),
|
||||
("workspaces", "searchspaces_user_id_fkey", "workspaces_user_id_fkey"),
|
||||
]
|
||||
|
||||
# (old_index, new_index) -- plain indexes only; PK/unique-backed indexes follow
|
||||
# their RENAME CONSTRAINT above. ALTER INDEX IF EXISTS covers both create_all
|
||||
# indexes and the runtime ``setup_indexes()`` ones (idx_documents_*).
|
||||
INDEX_RENAMES: list[tuple[str, str]] = [
|
||||
("ix_agent_action_log_search_space_id", "ix_agent_action_log_workspace_id"),
|
||||
(
|
||||
"ix_agent_permission_rules_search_space_id",
|
||||
"ix_agent_permission_rules_workspace_id",
|
||||
),
|
||||
("ix_automations_search_space_id", "ix_automations_workspace_id"),
|
||||
("ix_document_files_search_space_id", "ix_document_files_workspace_id"),
|
||||
("ix_document_revisions_search_space_id", "ix_document_revisions_workspace_id"),
|
||||
(
|
||||
"ix_external_chat_bindings_search_space_state",
|
||||
"ix_external_chat_bindings_workspace_state",
|
||||
),
|
||||
("ix_folder_revisions_search_space_id", "ix_folder_revisions_workspace_id"),
|
||||
("ix_folders_search_space_id", "ix_folders_workspace_id"),
|
||||
("ix_notifications_search_space_id", "ix_notifications_workspace_id"),
|
||||
("ix_notifications_user_space_created", "ix_notifications_user_workspace_created"),
|
||||
("ix_prompts_search_space_id", "ix_prompts_workspace_id"),
|
||||
("ix_token_usage_search_space_id", "ix_token_usage_workspace_id"),
|
||||
("ix_search_space_invites_created_at", "ix_workspace_invites_created_at"),
|
||||
("ix_search_space_invites_id", "ix_workspace_invites_id"),
|
||||
("ix_search_space_invites_invite_code", "ix_workspace_invites_invite_code"),
|
||||
("ix_search_space_memberships_created_at", "ix_workspace_memberships_created_at"),
|
||||
("ix_search_space_memberships_id", "ix_workspace_memberships_id"),
|
||||
("ix_search_space_roles_created_at", "ix_workspace_roles_created_at"),
|
||||
("ix_search_space_roles_id", "ix_workspace_roles_id"),
|
||||
("ix_search_space_roles_name", "ix_workspace_roles_name"),
|
||||
("ix_searchspaces_created_at", "ix_workspaces_created_at"),
|
||||
("ix_searchspaces_id", "ix_workspaces_id"),
|
||||
("ix_searchspaces_name", "ix_workspaces_name"),
|
||||
("idx_documents_search_space_id", "idx_documents_workspace_id"),
|
||||
("idx_documents_search_space_updated", "idx_documents_workspace_updated"),
|
||||
]
|
||||
|
||||
# (old_sequence, new_sequence)
|
||||
SEQUENCE_RENAMES: list[tuple[str, str]] = [
|
||||
("searchspaces_id_seq", "workspaces_id_seq"),
|
||||
("search_space_roles_id_seq", "workspace_roles_id_seq"),
|
||||
("search_space_memberships_id_seq", "workspace_memberships_id_seq"),
|
||||
("search_space_invites_id_seq", "workspace_invites_id_seq"),
|
||||
]
|
||||
|
||||
# ---- Hardcoded OLD-shape publication (for downgrade only; finding 7). ----
|
||||
# Mirrors app.zero_publication.ZERO_PUBLICATION at revision 169 but with the
|
||||
# pre-rename ``search_space_id`` column. NEVER import the live module here: it
|
||||
# now reflects ``workspace_id`` and would silently drop the column-list tables.
|
||||
_DOWNGRADE_DOCUMENT_COLS = [
|
||||
"id",
|
||||
"title",
|
||||
"document_type",
|
||||
"search_space_id",
|
||||
"folder_id",
|
||||
"created_by_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
_DOWNGRADE_USER_COLS = ["id", "credit_micros_balance"]
|
||||
_DOWNGRADE_AUTOMATION_RUN_COLS = [
|
||||
"id",
|
||||
"automation_id",
|
||||
"trigger_id",
|
||||
"status",
|
||||
"step_results",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"created_at",
|
||||
]
|
||||
_DOWNGRADE_AUTOMATION_COLS = ["id", "search_space_id"]
|
||||
_DOWNGRADE_NEW_CHAT_THREAD_COLS = ["id", "search_space_id"]
|
||||
_DOWNGRADE_PODCAST_COLS = [
|
||||
"id",
|
||||
"title",
|
||||
"status",
|
||||
"spec",
|
||||
"spec_version",
|
||||
"duration_seconds",
|
||||
"error",
|
||||
"search_space_id",
|
||||
"thread_id",
|
||||
"created_at",
|
||||
]
|
||||
# Ordered to match ZERO_PUBLICATION; None == all columns.
|
||||
_DOWNGRADE_PUBLICATION: list[tuple[str, list[str] | None]] = [
|
||||
("notifications", None),
|
||||
("documents", _DOWNGRADE_DOCUMENT_COLS),
|
||||
("folders", None),
|
||||
("search_source_connectors", None),
|
||||
("new_chat_threads", _DOWNGRADE_NEW_CHAT_THREAD_COLS),
|
||||
("new_chat_messages", None),
|
||||
("chat_comments", None),
|
||||
("chat_session_state", None),
|
||||
("user", _DOWNGRADE_USER_COLS),
|
||||
("automations", _DOWNGRADE_AUTOMATION_COLS),
|
||||
("automation_runs", _DOWNGRADE_AUTOMATION_RUN_COLS),
|
||||
("podcasts", _DOWNGRADE_PODCAST_COLS),
|
||||
]
|
||||
|
||||
|
||||
def _quote(identifier: str) -> str:
|
||||
return '"' + identifier.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _publication_exists(conn) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :n"),
|
||||
{"n": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _is_publication_member(conn, table: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_publication_tables "
|
||||
"WHERE pubname = :n AND schemaname = current_schema() "
|
||||
"AND tablename = :t"
|
||||
),
|
||||
{"n": PUBLICATION_NAME, "t": table},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _table_columns(conn, table: str) -> set[str]:
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() AND table_name = :t"
|
||||
),
|
||||
{"t": table},
|
||||
).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
|
||||
def _neutralize_column_list_tables(conn) -> None:
|
||||
"""Remove the column-list dependency so RENAME COLUMN is permitted."""
|
||||
if not _publication_exists(conn):
|
||||
return
|
||||
for table in PUBLICATION_COLUMN_LIST_TABLES:
|
||||
if _is_publication_member(conn, table):
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} DROP TABLE {table}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _rename_table(conn, old: str, new: str) -> None:
|
||||
conn.execute(sa.text(f"ALTER TABLE IF EXISTS {old} RENAME TO {new}"))
|
||||
|
||||
|
||||
def _rename_column(conn, table: str, old: str, new: str) -> None:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = '{table}' AND column_name = '{old}'
|
||||
) THEN
|
||||
ALTER TABLE {table} RENAME COLUMN {old} TO {new};
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _rename_constraint(conn, table: str, old: str, new: str) -> None:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = '{old}'
|
||||
AND conrelid = to_regclass('{table}')
|
||||
) THEN
|
||||
ALTER TABLE {table} RENAME CONSTRAINT {old} TO {new};
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _rename_index(conn, old: str, new: str) -> None:
|
||||
conn.execute(sa.text(f"ALTER INDEX IF EXISTS {old} RENAME TO {new}"))
|
||||
|
||||
|
||||
def _rename_sequence(conn, old: str, new: str) -> None:
|
||||
conn.execute(sa.text(f"ALTER SEQUENCE IF EXISTS {old} RENAME TO {new}"))
|
||||
|
||||
|
||||
def _restore_downgrade_publication(conn) -> None:
|
||||
"""Re-emit the OLD (search_space_id) publication shape via plain SET TABLE.
|
||||
|
||||
Does NOT call apply_publication (which reads the live, now-workspace_id
|
||||
module) -- that would silently drop the column-list tables (finding 7).
|
||||
"""
|
||||
if not _publication_exists(conn):
|
||||
return
|
||||
# Drop the column-list tables first so the SET TABLE has no stale
|
||||
# workspace_id dependency to trip over.
|
||||
for table in PUBLICATION_COLUMN_LIST_TABLES:
|
||||
if _is_publication_member(conn, table):
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} DROP TABLE {table}"
|
||||
)
|
||||
)
|
||||
|
||||
entries: list[str] = []
|
||||
for table, cols in _DOWNGRADE_PUBLICATION:
|
||||
actual = _table_columns(conn, table)
|
||||
if not actual:
|
||||
continue
|
||||
if cols is None:
|
||||
entries.append(_quote(table))
|
||||
continue
|
||||
expected = list(cols)
|
||||
if table in {"documents", "user", "podcasts"} and "_0_version" in actual:
|
||||
expected.append("_0_version")
|
||||
if any(col not in actual for col in expected):
|
||||
continue
|
||||
col_sql = ", ".join(_quote(col) for col in expected)
|
||||
entries.append(f"{_quote(table)} ({col_sql})")
|
||||
|
||||
table_list = ", ".join(entries)
|
||||
conn.execute(
|
||||
sa.text(f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} SET TABLE {table_list}")
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
_neutralize_column_list_tables(conn)
|
||||
|
||||
for old, new in TABLE_RENAMES:
|
||||
_rename_table(conn, old, new)
|
||||
for table, old, new in COLUMN_RENAMES:
|
||||
_rename_column(conn, table, old, new)
|
||||
for table, old, new in CONSTRAINT_RENAMES:
|
||||
_rename_constraint(conn, table, old, new)
|
||||
for old, new in INDEX_RENAMES:
|
||||
_rename_index(conn, old, new)
|
||||
for old, new in SEQUENCE_RENAMES:
|
||||
_rename_sequence(conn, old, new)
|
||||
|
||||
# Reconcile to the new workspace_id shape via the blessed apply_publication
|
||||
# path (ALTER ... SET TABLE) -- never raw DROP/CREATE PUBLICATION (bug #1355,
|
||||
# migration 116). No-op if the publication does not exist.
|
||||
apply_publication(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
_neutralize_column_list_tables(conn)
|
||||
|
||||
for old, new in SEQUENCE_RENAMES:
|
||||
_rename_sequence(conn, new, old)
|
||||
for old, new in INDEX_RENAMES:
|
||||
_rename_index(conn, new, old)
|
||||
for table, old, new in CONSTRAINT_RENAMES:
|
||||
# table here is the POST-rename name; constraints/tables are still
|
||||
# renamed at this point (table rename is reversed last).
|
||||
_rename_constraint(conn, table, new, old)
|
||||
for table, old, new in COLUMN_RENAMES:
|
||||
_rename_column(conn, table, new, old)
|
||||
for old, new in TABLE_RENAMES:
|
||||
_rename_table(conn, new, old)
|
||||
|
||||
_restore_downgrade_publication(conn)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""Add runs and tool_output_spills tables.
|
||||
|
||||
``runs`` backs the user-facing Scraper-API logs and the agent's tool-boundary
|
||||
truncation (full scraper output stored here, model sees a capped preview + id).
|
||||
``tool_output_spills`` is the internal scratch store for main-agent context
|
||||
spills, kept separate so the customer log stays clean.
|
||||
|
||||
Revision ID: 171
|
||||
Revises: 170
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "171"
|
||||
down_revision: str | None = "170"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
thread_id VARCHAR(255),
|
||||
capability VARCHAR(100) NOT NULL,
|
||||
origin VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
error TEXT,
|
||||
input JSONB,
|
||||
output_text TEXT,
|
||||
item_count INTEGER NOT NULL DEFAULT 0,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
cost_micros BIGINT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_workspace_id ON runs (workspace_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_user_id ON runs (user_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_capability ON runs (capability)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_created_at ON runs (created_at)")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_runs_workspace_created "
|
||||
"ON runs (workspace_id, created_at)"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tool_output_spills (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
thread_id VARCHAR(255),
|
||||
tool_name VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tool_output_spills_workspace_id "
|
||||
"ON tool_output_spills (workspace_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tool_output_spills_created_at "
|
||||
"ON tool_output_spills (created_at)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS tool_output_spills")
|
||||
op.execute("DROP TABLE IF EXISTS runs")
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""Remove AI file sort flag.
|
||||
|
||||
Revision ID: 172
|
||||
Revises: 171
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "172"
|
||||
down_revision: str | None = "171"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("workspaces", "ai_file_sort_enabled")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"workspaces",
|
||||
sa.Column(
|
||||
"ai_file_sort_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
26
surfsense_backend/alembic/versions/173_add_runs_progress.py
Normal file
26
surfsense_backend/alembic/versions/173_add_runs_progress.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Add runs.progress for streamed scraper run progress.
|
||||
|
||||
Stores the coarse (throttled) progress log captured during a run; the live
|
||||
fine-grained stream stays ephemeral (in-process bus / SSE only). Nullable so
|
||||
existing rows and the sync/agent doors that don't report progress are unaffected.
|
||||
|
||||
Revision ID: 173
|
||||
Revises: 172
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "173"
|
||||
down_revision: str | None = "172"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress JSONB")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE runs DROP COLUMN IF EXISTS progress")
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
"""Minimal anonymous / free-chat agent.
|
||||
|
||||
The no-login chat experience must stay dead simple: the user asks a question
|
||||
and the model answers, optionally using ``web_search`` and an optionally
|
||||
uploaded **read-only** document. We deliberately bypass the full SurfSense deep
|
||||
agent stack (filesystem, file-intent, knowledge-base persistence, subagents,
|
||||
skills, memory) because those middlewares stage or persist "documents" that an
|
||||
anonymous session can never see again -- which produced phantom
|
||||
"I saved it to a file" answers for free users.
|
||||
and the model answers over an optionally uploaded **read-only** document. We
|
||||
deliberately bypass the full SurfSense deep agent stack (filesystem,
|
||||
file-intent, knowledge-base persistence, subagents, skills, memory) because
|
||||
those middlewares stage or persist "documents" that an anonymous session can
|
||||
never see again -- which produced phantom "I saved it to a file" answers for
|
||||
free users.
|
||||
|
||||
For any other SurfSense capability the model is instructed (via the system
|
||||
prompt built here) to tell the user to create a free account instead of
|
||||
pretending to perform the action.
|
||||
For any other SurfSense capability (including web search) the model is
|
||||
instructed (via the system prompt built here) to tell the user to create a
|
||||
free account instead of pretending to perform the action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,7 +22,6 @@ from deepagents.backends import StateBackend
|
|||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import (
|
||||
ModelCallLimitMiddleware,
|
||||
ToolCallLimitMiddleware,
|
||||
)
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langgraph.types import Checkpointer
|
||||
|
|
@ -32,7 +31,6 @@ from app.agents.chat.shared.middleware import (
|
|||
RetryAfterMiddleware,
|
||||
create_surfsense_compaction_middleware,
|
||||
)
|
||||
from app.agents.chat.shared.tools.web_search import create_web_search_tool
|
||||
|
||||
# Cap how much of an uploaded document we inline into the system prompt. The
|
||||
# upload endpoint allows files up to several MB, but the doc is re-sent on
|
||||
|
|
@ -43,9 +41,12 @@ _MAX_DOC_CHARS = 50_000
|
|||
def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str:
|
||||
"""Build the system prompt for the minimal anonymous chat agent.
|
||||
|
||||
The prompt keeps the assistant focused on plain Q/A + web search, inlines
|
||||
any uploaded document as read-only context, and redirects every other
|
||||
SurfSense feature to account registration.
|
||||
The prompt keeps the assistant focused on plain Q/A from model knowledge,
|
||||
inlines any uploaded document as read-only context, and treats the chat as
|
||||
a registration funnel: every other SurfSense capability (scraping, live
|
||||
data, deliverables, knowledge base, automations) redirects to sign-up, and
|
||||
the assistant softly suggests an account when the conversation reveals a
|
||||
competitive-intelligence need the platform serves.
|
||||
"""
|
||||
today = datetime.now(UTC).strftime("%A, %B %d, %Y")
|
||||
|
||||
|
|
@ -72,30 +73,56 @@ def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str
|
|||
|
||||
return (
|
||||
"You are SurfSense's free AI assistant, available to everyone without "
|
||||
"login.\n\n"
|
||||
"login. SurfSense is the open-source competitive intelligence platform: "
|
||||
"registered users get specialist agents that pull live market data from "
|
||||
"Reddit, YouTube, Google Maps, Google Search, and the open web, turn it "
|
||||
"into cited briefs, reports, podcasts, and presentations, keep findings "
|
||||
"in a searchable knowledge base, and run scheduled monitoring "
|
||||
"automations — plus a REST scraping API and MCP server for their own "
|
||||
"agents.\n\n"
|
||||
f"Today's date is {today}.\n\n"
|
||||
"## How to help\n"
|
||||
"- Answer the user's questions directly and conversationally. You are "
|
||||
"a straightforward question-and-answer assistant.\n"
|
||||
"- When a question needs current, real-time, or factual information "
|
||||
"from the internet (news, prices, weather, recent events, live data), "
|
||||
"use the `web_search` tool. Otherwise, answer directly from your own "
|
||||
"knowledge.\n"
|
||||
"- Answer from your own knowledge. You do NOT have web access here, so "
|
||||
"for current, real-time, or fast-changing facts (news, prices, "
|
||||
"weather, recent events, live data) say you can't look them up in the "
|
||||
"free experience and may be out of date.\n"
|
||||
"- Be concise, accurate, and helpful. Use Markdown formatting when it "
|
||||
"improves readability."
|
||||
f"{doc_section}\n\n"
|
||||
"## What is not available here\n"
|
||||
"This is the free, no-login experience. You CANNOT save files or "
|
||||
"notes, generate reports, podcasts, resumes, presentations, or images, "
|
||||
"search or build a knowledge base, connect to apps (Gmail, Google "
|
||||
"Drive, Notion, Slack, Calendar, Discord, and similar), set up "
|
||||
"automations, or remember anything across sessions.\n\n"
|
||||
"This is the free, no-login experience. You CANNOT search the web or "
|
||||
"scrape any platform (Reddit, YouTube, Google Maps, Google Search, "
|
||||
"websites), save files or notes, upload additional files, generate "
|
||||
"reports, podcasts, resumes, presentations, or images, search or build "
|
||||
"a knowledge base, connect to apps (Gmail, Google Drive, Notion, "
|
||||
"Slack, Calendar, Discord, and similar), set up automations or "
|
||||
"monitoring, use the scraping API or MCP server, or remember anything "
|
||||
"across sessions.\n\n"
|
||||
"If the user asks for any of these, do NOT pretend to do them and "
|
||||
"never claim you saved, created, or stored anything. Instead, briefly "
|
||||
"let them know the feature requires a free SurfSense account and "
|
||||
"invite them to create one at https://www.surfsense.com. Then offer to "
|
||||
"help with what you can do here (answering questions and searching the "
|
||||
"web)."
|
||||
"never claim you saved, created, scraped, or stored anything. Instead, "
|
||||
"briefly say that this needs a free SurfSense account and invite them "
|
||||
"to sign up at https://www.surfsense.com/register — name the specific "
|
||||
"capability that would solve their request (e.g. 'a SurfSense account "
|
||||
"can scrape those Reddit threads live and turn them into a cited "
|
||||
"brief'). Then offer to help with what you can do here (answering from "
|
||||
"your own knowledge and about any uploaded document).\n\n"
|
||||
"## Suggesting SurfSense when it genuinely fits\n"
|
||||
"You are also the front door to SurfSense. When the conversation "
|
||||
"reveals a need the full platform serves — researching competitors, "
|
||||
"tracking pricing or rankings, monitoring brand mentions or reviews, "
|
||||
"gauging Reddit/YouTube sentiment, generating leads, needing current "
|
||||
"web data, or wanting recurring reports — first answer as well as you "
|
||||
"can from your own knowledge, then add ONE short sentence pointing out "
|
||||
"that a free SurfSense account can do that with live data, linking "
|
||||
"https://www.surfsense.com/register.\n"
|
||||
"- Be helpful first, never salesy: the answer is the product; the "
|
||||
"suggestion is a footnote.\n"
|
||||
"- At most one suggestion per response, and stop suggesting entirely "
|
||||
"if the user declines or ignores it.\n"
|
||||
"- Do not suggest it for needs SurfSense does not serve (casual chat, "
|
||||
"coding help, homework, creative writing)."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -105,14 +132,13 @@ async def create_anonymous_chat_agent(
|
|||
checkpointer: Checkpointer,
|
||||
anon_session_id: str | None = None,
|
||||
anon_doc: dict[str, Any] | None = None,
|
||||
enable_web_search: bool = True,
|
||||
):
|
||||
"""Create a minimal Q/A agent for anonymous / free chat.
|
||||
|
||||
Unlike :func:`create_surfsense_deep_agent`, this agent has no filesystem,
|
||||
file-intent, knowledge-base persistence, subagent, skills, or memory
|
||||
middleware. Its only tool is ``web_search`` (when ``enable_web_search`` is
|
||||
True), and any uploaded document is injected into the system prompt as
|
||||
middleware -- and no tools at all. It answers purely from the model's own
|
||||
knowledge; any uploaded document is injected into the system prompt as
|
||||
read-only context.
|
||||
|
||||
Args:
|
||||
|
|
@ -120,36 +146,23 @@ async def create_anonymous_chat_agent(
|
|||
checkpointer: LangGraph checkpointer for the ephemeral anon thread.
|
||||
anon_session_id: Anonymous session id (used only for telemetry/metadata).
|
||||
anon_doc: Optional ``{"title", "content"}`` for an uploaded document.
|
||||
enable_web_search: When False, the agent runs as a pure LLM with no
|
||||
tools (used when the user toggles web search off).
|
||||
"""
|
||||
tools = (
|
||||
[create_web_search_tool(search_space_id=None, available_connectors=None)]
|
||||
if enable_web_search
|
||||
else []
|
||||
)
|
||||
|
||||
# Reliability-only middleware. Nothing here touches the database or
|
||||
# filesystem: call limits guard against loops, compaction summarises long
|
||||
# histories into in-graph state, and retry handles provider rate limits.
|
||||
# filesystem: the call limit guards against loops, compaction summarises
|
||||
# long histories into in-graph state, and retry handles provider rate
|
||||
# limits.
|
||||
middleware: list[Any] = [
|
||||
ModelCallLimitMiddleware(thread_limit=120, run_limit=80, exit_behavior="end"),
|
||||
create_surfsense_compaction_middleware(llm, StateBackend),
|
||||
RetryAfterMiddleware(max_retries=3),
|
||||
]
|
||||
if tools:
|
||||
middleware.append(
|
||||
ToolCallLimitMiddleware(
|
||||
thread_limit=300, run_limit=80, exit_behavior="continue"
|
||||
)
|
||||
)
|
||||
middleware.append(create_surfsense_compaction_middleware(llm, StateBackend))
|
||||
middleware.append(RetryAfterMiddleware(max_retries=3))
|
||||
|
||||
system_prompt = build_anonymous_system_prompt(anon_doc)
|
||||
|
||||
agent = create_agent(
|
||||
llm,
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
tools=[],
|
||||
middleware=middleware,
|
||||
context_schema=SurfSenseContextSchema,
|
||||
checkpointer=checkpointer,
|
||||
|
|
|
|||
|
|
@ -2,43 +2,74 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
# Connected apps (hosted MCP + interim-native Gmail/Calendar) all route to the
|
||||
# single ``mcp_discovery`` subagent. File connectors stay native (they enrich
|
||||
# the knowledge base). Deprecated connectors (Discord/Teams/Luma) are omitted:
|
||||
# they have no agent subagent, so their rows produce no tools.
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
|
||||
"GOOGLE_GMAIL_CONNECTOR": "gmail",
|
||||
"COMPOSIO_GMAIL_CONNECTOR": "gmail",
|
||||
"GOOGLE_CALENDAR_CONNECTOR": "calendar",
|
||||
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "calendar",
|
||||
"DISCORD_CONNECTOR": "discord",
|
||||
"TEAMS_CONNECTOR": "teams",
|
||||
"LUMA_CONNECTOR": "luma",
|
||||
"LINEAR_CONNECTOR": "linear",
|
||||
"JIRA_CONNECTOR": "jira",
|
||||
"CLICKUP_CONNECTOR": "clickup",
|
||||
"SLACK_CONNECTOR": "slack",
|
||||
"AIRTABLE_CONNECTOR": "airtable",
|
||||
"NOTION_CONNECTOR": "notion",
|
||||
"CONFLUENCE_CONNECTOR": "confluence",
|
||||
"GOOGLE_GMAIL_CONNECTOR": "mcp_discovery",
|
||||
"COMPOSIO_GMAIL_CONNECTOR": "mcp_discovery",
|
||||
"GOOGLE_CALENDAR_CONNECTOR": "mcp_discovery",
|
||||
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "mcp_discovery",
|
||||
"LINEAR_CONNECTOR": "mcp_discovery",
|
||||
"JIRA_CONNECTOR": "mcp_discovery",
|
||||
"CLICKUP_CONNECTOR": "mcp_discovery",
|
||||
"SLACK_CONNECTOR": "mcp_discovery",
|
||||
"AIRTABLE_CONNECTOR": "mcp_discovery",
|
||||
"NOTION_CONNECTOR": "mcp_discovery",
|
||||
"CONFLUENCE_CONNECTOR": "mcp_discovery",
|
||||
"MCP_CONNECTOR": "mcp_discovery",
|
||||
"GOOGLE_DRIVE_CONNECTOR": "google_drive",
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "google_drive",
|
||||
"DROPBOX_CONNECTOR": "dropbox",
|
||||
"ONEDRIVE_CONNECTOR": "onedrive",
|
||||
}
|
||||
|
||||
# ``mcp_discovery`` is gated any-of: present iff the workspace has at least one
|
||||
# connected app. Tokens are searchable-type strings (Composio Gmail/Calendar
|
||||
# map to the GOOGLE_* tokens in connector_searchable_types).
|
||||
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
|
||||
"deliverables": frozenset(),
|
||||
"knowledge_base": frozenset(),
|
||||
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
|
||||
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
|
||||
"clickup": frozenset({"CLICKUP_CONNECTOR"}),
|
||||
"confluence": frozenset({"CONFLUENCE_CONNECTOR"}),
|
||||
"discord": frozenset({"DISCORD_CONNECTOR"}),
|
||||
"web_crawler": frozenset(),
|
||||
"youtube": frozenset(),
|
||||
"google_maps": frozenset(),
|
||||
"google_search": frozenset(),
|
||||
"reddit": frozenset(),
|
||||
"mcp_discovery": frozenset(
|
||||
{
|
||||
"SLACK_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"CLICKUP_CONNECTOR",
|
||||
"AIRTABLE_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"CONFLUENCE_CONNECTOR",
|
||||
"GOOGLE_GMAIL_CONNECTOR",
|
||||
"GOOGLE_CALENDAR_CONNECTOR",
|
||||
"MCP_CONNECTOR",
|
||||
}
|
||||
),
|
||||
"dropbox": frozenset({"DROPBOX_FILE"}),
|
||||
"gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR"}),
|
||||
"google_drive": frozenset({"GOOGLE_DRIVE_FILE"}),
|
||||
"jira": frozenset({"JIRA_CONNECTOR"}),
|
||||
"linear": frozenset({"LINEAR_CONNECTOR"}),
|
||||
"luma": frozenset({"LUMA_CONNECTOR"}),
|
||||
"notion": frozenset({"NOTION_CONNECTOR"}),
|
||||
"onedrive": frozenset({"ONEDRIVE_FILE"}),
|
||||
"slack": frozenset({"SLACK_CONNECTOR"}),
|
||||
"teams": frozenset({"TEAMS_CONNECTOR"}),
|
||||
}
|
||||
|
||||
# Old per-connector subagent names, kept working for checkpoint resume: a
|
||||
# ``task(subagent_type="gmail")`` paused before the MCP consolidation resolves
|
||||
# to the consolidated ``mcp_discovery`` subagent instead of hard-failing
|
||||
# "subagent does not exist". New routing never emits these names.
|
||||
LEGACY_SUBAGENT_ALIASES: dict[str, str] = {
|
||||
"gmail": "mcp_discovery",
|
||||
"calendar": "mcp_discovery",
|
||||
"linear": "mcp_discovery",
|
||||
"jira": "mcp_discovery",
|
||||
"clickup": "mcp_discovery",
|
||||
"slack": "mcp_discovery",
|
||||
"airtable": "mcp_discovery",
|
||||
"notion": "mcp_discovery",
|
||||
"confluence": "mcp_discovery",
|
||||
"discord": "mcp_discovery",
|
||||
"teams": "mcp_discovery",
|
||||
"luma": "mcp_discovery",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def build_compiled_agent_graph_sync(
|
|||
final_system_prompt: str,
|
||||
backend_resolver: Any,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
thread_id: int | None,
|
||||
visibility: ChatVisibility,
|
||||
|
|
@ -53,7 +53,7 @@ def build_compiled_agent_graph_sync(
|
|||
tools=tools,
|
||||
backend_resolver=backend_resolver,
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
thread_id=thread_id,
|
||||
visibility=visibility,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ def build_action_log_mw(
|
|||
*,
|
||||
flags: AgentFeatureFlags,
|
||||
thread_id: int | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
) -> ActionLogMiddleware | None:
|
||||
if not enabled(flags, "enable_action_log") or thread_id is None:
|
||||
|
|
@ -25,7 +25,7 @@ def build_action_log_mw(
|
|||
# tool via ``ToolDefinition.reverse`` and can be wired here when used.
|
||||
return ActionLogMiddleware(
|
||||
thread_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class ActionLogMiddleware(AgentMiddleware):
|
|||
Args:
|
||||
thread_id: The current chat thread's primary-key id. Required to
|
||||
persist a row; if ``None`` the middleware silently no-ops.
|
||||
search_space_id: Search-space id for cascade-on-delete safety.
|
||||
workspace_id: Workspace id for cascade-on-delete safety.
|
||||
user_id: UUID string of the user driving this turn (nullable in
|
||||
anonymous mode).
|
||||
tool_definitions: Optional mapping of tool name -> :class:`ToolDefinition`
|
||||
|
|
@ -98,13 +98,13 @@ class ActionLogMiddleware(AgentMiddleware):
|
|||
self,
|
||||
*,
|
||||
thread_id: int | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
tool_definitions: dict[str, ToolDefinition] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._thread_id = thread_id
|
||||
self._search_space_id = search_space_id
|
||||
self._workspace_id = workspace_id
|
||||
self._user_id = user_id
|
||||
self._tool_definitions = dict(tool_definitions or {})
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ class ActionLogMiddleware(AgentMiddleware):
|
|||
row = AgentActionLog(
|
||||
thread_id=thread_id,
|
||||
user_id=self._user_id,
|
||||
search_space_id=self._search_space_id,
|
||||
workspace_id=self._workspace_id,
|
||||
# ``turn_id`` is the deprecated alias of ``tool_call_id``
|
||||
# kept for one release for safe rollback. New consumers
|
||||
# should read ``tool_call_id`` directly.
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
|
|||
subagents: list[SubAgent | CompiledSubAgent],
|
||||
system_prompt: str | None = TASK_SYSTEM_PROMPT,
|
||||
task_description: str | None = None,
|
||||
search_space_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
) -> None:
|
||||
self._surf_checkpointer = checkpointer
|
||||
super(SubAgentMiddleware, self).__init__()
|
||||
|
|
@ -50,11 +50,11 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
|
|||
)
|
||||
self._backend = backend
|
||||
self._subagents = subagents
|
||||
# Search-space id is captured at build time (the orchestrator runs in
|
||||
# exactly one search space for its lifetime). The spawn-paused kill
|
||||
# Workspace id is captured at build time (the orchestrator runs in
|
||||
# exactly one workspace for its lifetime). The spawn-paused kill
|
||||
# switch keys on it so an operator can quarantine one workspace
|
||||
# without affecting the rest of the deployment.
|
||||
self._search_space_id = search_space_id
|
||||
self._workspace_id = workspace_id
|
||||
|
||||
# Lazy subagent compilation. Compiling a subagent graph via
|
||||
# ``create_agent`` is expensive (~250-400ms each) and there can be up
|
||||
|
|
@ -75,7 +75,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
|
|||
task_tool = build_task_tool_with_parent_config(
|
||||
descriptors,
|
||||
task_description,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
resolve_subagent=self._resolve_subagent,
|
||||
)
|
||||
if system_prompt and descriptors:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""Per-search-space spawn-paused kill switch for the ``task`` boundary.
|
||||
"""Per-workspace spawn-paused kill switch for the ``task`` boundary.
|
||||
|
||||
When operators see a runaway loop, a vendor outage, or a billing event
|
||||
that requires immediate cessation of subagent traffic for a specific
|
||||
workspace, they flip a Redis flag and the ``task`` tool short-circuits
|
||||
without touching downstream services. The flag is **per-search-space**
|
||||
without touching downstream services. The flag is **per-workspace**
|
||||
so one tenant's incident never silences the rest of the deployment.
|
||||
|
||||
Flag key: ``surfsense:spawn_paused:{search_space_id}``
|
||||
Flag key: ``surfsense:spawn_paused:{workspace_id}``
|
||||
Flag value: any string-truthy value (we read presence, not contents).
|
||||
TTL: set by whoever toggles the flag — this module never expires
|
||||
keys on its own, since "the flag is on" is itself the signal
|
||||
|
|
@ -43,18 +43,18 @@ _DISABLED = os.environ.get(
|
|||
}
|
||||
|
||||
|
||||
def _flag_key(search_space_id: int) -> str:
|
||||
return f"surfsense:spawn_paused:{search_space_id}"
|
||||
def _flag_key(workspace_id: int) -> str:
|
||||
return f"surfsense:spawn_paused:{workspace_id}"
|
||||
|
||||
|
||||
async def is_spawn_paused(search_space_id: int | None) -> bool:
|
||||
async def is_spawn_paused(workspace_id: int | None) -> bool:
|
||||
"""Return ``True`` iff the workspace's spawn-paused flag is set in Redis.
|
||||
|
||||
A ``None`` search-space (e.g. dev paths that did not plumb the id
|
||||
A ``None`` workspace (e.g. dev paths that did not plumb the id
|
||||
through yet) bypasses the check. So does a Redis outage — see module
|
||||
docstring for the fail-open rationale.
|
||||
"""
|
||||
if _DISABLED or search_space_id is None:
|
||||
if _DISABLED or workspace_id is None:
|
||||
return False
|
||||
try:
|
||||
# Local import keeps the cold-path import cheap and lets routes
|
||||
|
|
@ -63,7 +63,7 @@ async def is_spawn_paused(search_space_id: int | None) -> bool:
|
|||
|
||||
client = aioredis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||
try:
|
||||
raw = await client.get(_flag_key(search_space_id))
|
||||
raw = await client.get(_flag_key(workspace_id))
|
||||
finally:
|
||||
# ``aclose()`` is the async-safe variant on redis-py >=5; fall back
|
||||
# to ``close()`` for older clients pinned in tests.
|
||||
|
|
@ -74,8 +74,8 @@ async def is_spawn_paused(search_space_id: int | None) -> bool:
|
|||
return bool(raw)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"spawn_paused check failed for search_space_id=%s; failing open.",
|
||||
search_space_id,
|
||||
"spawn_paused check failed for workspace_id=%s; failing open.",
|
||||
workspace_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from langchain_core.tools import StructuredTool
|
|||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.types import Command, Interrupt
|
||||
|
||||
from app.agents.chat.multi_agent_chat.constants import LEGACY_SUBAGENT_ALIASES
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.invocation import (
|
||||
EXCLUDED_STATE_KEYS,
|
||||
subagent_invoke_config,
|
||||
|
|
@ -142,7 +143,7 @@ def build_task_tool_with_parent_config(
|
|||
subagents: list[dict[str, Any]],
|
||||
task_description: str | None = None,
|
||||
*,
|
||||
search_space_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
resolve_subagent: Callable[[str], Runnable] | None = None,
|
||||
) -> BaseTool:
|
||||
"""Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging.
|
||||
|
|
@ -157,6 +158,26 @@ def build_task_tool_with_parent_config(
|
|||
case a trivial dict-backed resolver is used.
|
||||
"""
|
||||
subagent_names: set[str] = {spec["name"] for spec in subagents}
|
||||
|
||||
def _canonical_subagent_type(subagent_type: str) -> str:
|
||||
"""Resolve a legacy connector subagent name to its consolidated route.
|
||||
|
||||
Only rewrites when the requested name is unavailable but its alias is
|
||||
(checkpoint resume of a pre-consolidation ``task(...)`` call). Current
|
||||
routing never emits legacy names, so live traffic is untouched.
|
||||
"""
|
||||
if subagent_type in subagent_names:
|
||||
return subagent_type
|
||||
alias = LEGACY_SUBAGENT_ALIASES.get(subagent_type)
|
||||
if alias is not None and alias in subagent_names:
|
||||
logger.info(
|
||||
"[hitl_route] aliasing legacy subagent %r -> %r",
|
||||
subagent_type,
|
||||
alias,
|
||||
)
|
||||
return alias
|
||||
return subagent_type
|
||||
|
||||
if resolve_subagent is None:
|
||||
_eager_graphs: dict[str, Runnable] = {
|
||||
spec["name"]: spec["runnable"] for spec in subagents if "runnable" in spec
|
||||
|
|
@ -482,6 +503,7 @@ def build_task_tool_with_parent_config(
|
|||
batched HITL is intentionally out of scope.
|
||||
"""
|
||||
async with semaphore:
|
||||
subagent_type = _canonical_subagent_type(subagent_type)
|
||||
if subagent_type not in subagent_names:
|
||||
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
|
||||
return (
|
||||
|
|
@ -658,6 +680,7 @@ def build_task_tool_with_parent_config(
|
|||
"task: must provide either single-mode (`description`+`subagent_type`) "
|
||||
"or batch-mode (`tasks`)."
|
||||
)
|
||||
subagent_type = _canonical_subagent_type(subagent_type)
|
||||
if subagent_type not in subagent_names:
|
||||
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
|
||||
return (
|
||||
|
|
@ -829,10 +852,10 @@ def build_task_tool_with_parent_config(
|
|||
atask_start = time.perf_counter()
|
||||
# Ops kill switch: short-circuit every task() call for this workspace
|
||||
# so the orchestrator stops hammering downstream APIs.
|
||||
if await is_spawn_paused(search_space_id):
|
||||
if await is_spawn_paused(workspace_id):
|
||||
logger.warning(
|
||||
"[hitl_route] atask SPAWN_PAUSED: search_space_id=%s tool_call_id=%s",
|
||||
search_space_id,
|
||||
"[hitl_route] atask SPAWN_PAUSED: workspace_id=%s tool_call_id=%s",
|
||||
workspace_id,
|
||||
runtime.tool_call_id,
|
||||
)
|
||||
return (
|
||||
|
|
@ -867,6 +890,7 @@ def build_task_tool_with_parent_config(
|
|||
subagent_type,
|
||||
runtime.tool_call_id,
|
||||
)
|
||||
subagent_type = _canonical_subagent_type(subagent_type)
|
||||
if subagent_type not in subagent_names:
|
||||
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ def build_context_editing_mw(
|
|||
flags: AgentFeatureFlags,
|
||||
max_input_tokens: int | None,
|
||||
tools: Sequence[BaseTool],
|
||||
backend_resolver: Any,
|
||||
workspace_id: int | None = None,
|
||||
) -> SpillingContextEditingMiddleware | None:
|
||||
if not enabled(flags, "enable_context_editing") or not max_input_tokens:
|
||||
return None
|
||||
|
|
@ -46,5 +45,5 @@ def build_context_editing_mw(
|
|||
)
|
||||
return SpillingContextEditingMiddleware(
|
||||
edits=[spill_edit, clear_edit],
|
||||
backend_resolver=backend_resolver,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,25 +4,31 @@ SpillToBackendEdit + SpillingContextEditingMiddleware.
|
|||
LangChain's :class:`ClearToolUsesEdit` discards old ``ToolMessage.content``
|
||||
when the context-editing budget triggers, replacing the body with a fixed
|
||||
placeholder. That's lossy: anything the agent might want to revisit is
|
||||
gone. The spill-to-disk pattern (originally from OpenCode's
|
||||
gone. The spill pattern (originally from OpenCode's
|
||||
``opencode/packages/opencode/src/tool/truncate.ts``) keeps the prune
|
||||
behavior but writes the full original payload to the runtime backend
|
||||
under ``/tool_outputs/{thread_id}/{message_id}.txt`` first. The
|
||||
placeholder is then upgraded to point at the spill path so the agent
|
||||
(or a subagent) can read it back on demand.
|
||||
behavior but persists the full original payload first — to the
|
||||
``tool_output_spills`` table — and upgrades the placeholder to a
|
||||
``spill_<uuid>`` reference the agent can read back with the shared
|
||||
``read_run``/``search_run`` tools on demand.
|
||||
|
||||
Why the DB and not the runtime filesystem backend: the previous version
|
||||
wrote via ``backend.aupload_files``, which is a no-op on cloud
|
||||
(``KBPostgresBackend`` raises ``NotImplementedError``) and mismatched on
|
||||
desktop (paths must be ``/{mount_id}/...``), so spills were unrecoverable
|
||||
in production. A table works on every deployment and needs no sandbox.
|
||||
|
||||
Why this is a middleware subclass instead of a plain ``ContextEdit``:
|
||||
``ContextEdit.apply`` is sync, but writing to the backend is async. We
|
||||
capture the spill payloads inside ``apply`` and flush them via
|
||||
``await backend.aupload_files(...)`` from ``awrap_model_call`` *before*
|
||||
delegating to the handler, so the explore subagent can always read what
|
||||
the placeholder advertises.
|
||||
``ContextEdit.apply`` is sync, but the DB write is async. We generate the
|
||||
spill id and capture the payload inside ``apply`` (so the placeholder can
|
||||
reference the final id immediately) and flush the rows from
|
||||
``awrap_model_call`` *before* delegating to the handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -44,7 +50,6 @@ from langchain_core.messages.utils import count_tokens_approximately
|
|||
from langgraph.config import get_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepagents.backends.protocol import BackendProtocol
|
||||
from langchain.agents.middleware.types import (
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
|
|
@ -52,24 +57,27 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SPILL_PREFIX = "/tool_outputs"
|
||||
# Namespace for deterministic spill ids: the same (thread, message) always maps
|
||||
# to the same row, so re-running the edit on later model calls (edits apply to a
|
||||
# per-call copy of the messages, never to persisted state) re-references the
|
||||
# existing spill instead of inserting a duplicate every turn.
|
||||
_SPILL_NAMESPACE = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||
|
||||
|
||||
def _build_spill_placeholder(spill_path: str) -> str:
|
||||
def _spill_id_for(thread_id: str | None, message_key: str) -> uuid.UUID:
|
||||
return uuid.uuid5(_SPILL_NAMESPACE, f"{thread_id or 'no_thread'}:{message_key}")
|
||||
|
||||
|
||||
def _build_spill_placeholder(spill_id: uuid.UUID) -> str:
|
||||
"""Build the user-facing placeholder text shown to the model."""
|
||||
return (
|
||||
f"[cleared — full output at {spill_path}; ask the explore subagent to read it]"
|
||||
f"[cleared — full output stored as spill_{spill_id}; "
|
||||
"use read_run/search_run to read it]"
|
||||
)
|
||||
|
||||
|
||||
def _get_thread_id_or_session() -> str:
|
||||
"""Best-effort thread_id discovery for the spill path.
|
||||
|
||||
Falls back to a process-stable string if no LangGraph config is
|
||||
available (e.g. unit tests). The exact value doesn't matter as long
|
||||
as it's stable within one stream so the placeholder paths line up
|
||||
with the actual upload path.
|
||||
"""
|
||||
def _get_thread_id() -> str | None:
|
||||
"""Best-effort ``configurable.thread_id`` for the spill row (``None`` if absent)."""
|
||||
try:
|
||||
config = get_config()
|
||||
thread_id = config.get("configurable", {}).get("thread_id")
|
||||
|
|
@ -77,17 +85,18 @@ def _get_thread_id_or_session() -> str:
|
|||
return str(thread_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return "no_thread"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SpillToBackendEdit(ContextEdit):
|
||||
"""Capture-and-replace context edit that spills full tool output to the backend.
|
||||
"""Capture-and-replace context edit that spills full tool output to the DB.
|
||||
|
||||
Behaves like :class:`ClearToolUsesEdit` (same trigger / keep / exclude
|
||||
semantics) **and** records the original ``ToolMessage.content`` in
|
||||
:attr:`pending_spills` so the wrapping middleware can flush them
|
||||
before the model call.
|
||||
:attr:`pending_spills` so the wrapping middleware can flush the rows to
|
||||
``tool_output_spills`` before the model call. The spill id is generated up
|
||||
front so the placeholder can reference it immediately.
|
||||
|
||||
Args:
|
||||
trigger: Token threshold above which the edit fires.
|
||||
|
|
@ -97,8 +106,6 @@ class SpillToBackendEdit(ContextEdit):
|
|||
exclude_tools: Names of tools whose output is NOT spilled.
|
||||
clear_tool_inputs: Also clear the originating ``AIMessage.tool_calls``
|
||||
args when their pair is cleared.
|
||||
path_prefix: Path under the backend where spills are written.
|
||||
Default ``"/tool_outputs"``.
|
||||
"""
|
||||
|
||||
trigger: int = 100_000
|
||||
|
|
@ -106,12 +113,16 @@ class SpillToBackendEdit(ContextEdit):
|
|||
keep: int = 3
|
||||
clear_tool_inputs: bool = False
|
||||
exclude_tools: Sequence[str] = ()
|
||||
path_prefix: str = DEFAULT_SPILL_PREFIX
|
||||
|
||||
pending_spills: list[tuple[str, bytes]] = field(default_factory=list)
|
||||
# (spill_id, content_bytes, tool_name, thread_id)
|
||||
pending_spills: list[tuple[uuid.UUID, bytes, str | None, str | None]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def drain_pending(self) -> list[tuple[str, bytes]]:
|
||||
def drain_pending(
|
||||
self,
|
||||
) -> list[tuple[uuid.UUID, bytes, str | None, str | None]]:
|
||||
"""Return and clear the pending-spill list atomically."""
|
||||
with self._lock:
|
||||
out = list(self.pending_spills)
|
||||
|
|
@ -139,7 +150,7 @@ class SpillToBackendEdit(ContextEdit):
|
|||
if self.keep:
|
||||
candidates = candidates[: -self.keep]
|
||||
|
||||
thread_id = _get_thread_id_or_session()
|
||||
thread_id = _get_thread_id()
|
||||
excluded_tools = set(self.exclude_tools)
|
||||
|
||||
for idx, tool_message in candidates:
|
||||
|
|
@ -168,24 +179,23 @@ class SpillToBackendEdit(ContextEdit):
|
|||
if tool_name in excluded_tools:
|
||||
continue
|
||||
|
||||
message_id = tool_message.id or tool_message.tool_call_id or "unknown"
|
||||
spill_path = f"{self.path_prefix}/{thread_id}/{message_id}.txt"
|
||||
|
||||
message_key = tool_message.id or tool_message.tool_call_id or "unknown"
|
||||
spill_id = _spill_id_for(thread_id, message_key)
|
||||
original = tool_message.content
|
||||
payload = self._encode_payload(original)
|
||||
with self._lock:
|
||||
self.pending_spills.append((spill_path, payload))
|
||||
self.pending_spills.append((spill_id, payload, tool_name, thread_id))
|
||||
|
||||
messages[idx] = tool_message.model_copy(
|
||||
update={
|
||||
"artifact": None,
|
||||
"content": _build_spill_placeholder(spill_path),
|
||||
"content": _build_spill_placeholder(spill_id),
|
||||
"response_metadata": {
|
||||
**tool_message.response_metadata,
|
||||
"context_editing": {
|
||||
"cleared": True,
|
||||
"strategy": "spill_to_backend",
|
||||
"spill_path": spill_path,
|
||||
"strategy": "spill_to_db",
|
||||
"spill_id": str(spill_id),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -243,52 +253,30 @@ class SpillToBackendEdit(ContextEdit):
|
|||
)
|
||||
|
||||
|
||||
BackendResolver = "Callable[[Any], BackendProtocol] | BackendProtocol"
|
||||
|
||||
|
||||
class SpillingContextEditingMiddleware(ContextEditingMiddleware):
|
||||
""":class:`ContextEditingMiddleware` that flushes :class:`SpillToBackendEdit` writes.
|
||||
|
||||
Runs the configured edits as the parent does, then flushes any
|
||||
pending spills via the supplied backend resolver before delegating
|
||||
to the model handler. Spill failures are logged but never abort the
|
||||
model call — the placeholder text is already in the message, so the
|
||||
worst case is the agent gets a placeholder it cannot follow up on.
|
||||
Runs the configured edits as the parent does, then persists any pending
|
||||
spills to ``tool_output_spills`` before delegating to the model handler.
|
||||
Spill failures are logged but never abort the model call — the placeholder
|
||||
text is already in the message, so the worst case is the agent gets a
|
||||
placeholder it cannot follow up on.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
edits: Sequence[ContextEdit],
|
||||
backend_resolver: BackendResolver | None = None,
|
||||
workspace_id: int | None = None,
|
||||
token_count_method: str = "approximate",
|
||||
) -> None:
|
||||
super().__init__(edits=list(edits), token_count_method=token_count_method) # type: ignore[arg-type]
|
||||
self._backend_resolver = backend_resolver
|
||||
self._workspace_id = workspace_id
|
||||
|
||||
def _resolve_backend(self, request: ModelRequest) -> BackendProtocol | None:
|
||||
if self._backend_resolver is None:
|
||||
return None
|
||||
if callable(self._backend_resolver):
|
||||
try:
|
||||
from langchain.tools import ToolRuntime
|
||||
|
||||
tool_runtime = ToolRuntime(
|
||||
state=getattr(request, "state", {}),
|
||||
context=getattr(request.runtime, "context", None),
|
||||
stream_writer=getattr(request.runtime, "stream_writer", None),
|
||||
store=getattr(request.runtime, "store", None),
|
||||
config=getattr(request.runtime, "config", None) or {},
|
||||
tool_call_id=None,
|
||||
)
|
||||
return self._backend_resolver(tool_runtime)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve spill backend")
|
||||
return None
|
||||
return self._backend_resolver # type: ignore[return-value]
|
||||
|
||||
def _collect_pending(self) -> list[tuple[str, bytes]]:
|
||||
out: list[tuple[str, bytes]] = []
|
||||
def _collect_pending(
|
||||
self,
|
||||
) -> list[tuple[uuid.UUID, bytes, str | None, str | None]]:
|
||||
out: list[tuple[uuid.UUID, bytes, str | None, str | None]] = []
|
||||
for edit in self.edits:
|
||||
if isinstance(edit, SpillToBackendEdit):
|
||||
out.extend(edit.drain_pending())
|
||||
|
|
@ -321,28 +309,37 @@ class SpillingContextEditingMiddleware(ContextEditingMiddleware):
|
|||
|
||||
pending = self._collect_pending()
|
||||
if pending:
|
||||
backend = self._resolve_backend(request)
|
||||
if backend is not None:
|
||||
try:
|
||||
await backend.aupload_files(pending)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Spill-to-backend upload failed (%d files); placeholders "
|
||||
"remain in messages but content is unrecoverable",
|
||||
len(pending),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"SpillToBackendEdit produced %d pending spills but no backend "
|
||||
"resolver was configured; content is unrecoverable",
|
||||
len(pending),
|
||||
)
|
||||
await self._flush_spills(pending)
|
||||
|
||||
return await handler(request.override(messages=edited_messages))
|
||||
|
||||
async def _flush_spills(
|
||||
self, pending: list[tuple[uuid.UUID, bytes, str | None, str | None]]
|
||||
) -> None:
|
||||
"""Persist spilled tool outputs to the DB (best-effort)."""
|
||||
from app.capabilities.core.runs import record_spill
|
||||
from app.db import async_session_maker
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
for spill_id, payload, tool_name, thread_id in pending:
|
||||
await record_spill(
|
||||
session,
|
||||
spill_id=spill_id,
|
||||
content=payload.decode("utf-8", errors="replace"),
|
||||
workspace_id=self._workspace_id,
|
||||
thread_id=thread_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Spill-to-DB flush failed (%d rows); placeholders remain in "
|
||||
"messages but content is unrecoverable",
|
||||
len(pending),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SPILL_PREFIX",
|
||||
"ClearToolUsesEdit",
|
||||
"SpillToBackendEdit",
|
||||
"SpillingContextEditingMiddleware",
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ from .middleware import (
|
|||
def build_kb_persistence_mw(
|
||||
*,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
thread_id: int | None,
|
||||
) -> KnowledgeBasePersistenceMiddleware | None:
|
||||
if filesystem_mode != FilesystemMode.CLOUD:
|
||||
return None
|
||||
return KnowledgeBasePersistenceMiddleware(
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=user_id,
|
||||
filesystem_mode=filesystem_mode,
|
||||
thread_id=thread_id,
|
||||
|
|
|
|||
|
|
@ -83,11 +83,11 @@ def _basename(path: str) -> str:
|
|||
async def _ensure_folder_hierarchy(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str | None,
|
||||
folder_parts: list[str],
|
||||
) -> int | None:
|
||||
"""Ensure a chain of folder names exists under the search space.
|
||||
"""Ensure a chain of folder names exists under the workspace.
|
||||
|
||||
Returns the leaf folder id, or ``None`` if ``folder_parts`` is empty
|
||||
(i.e. a document directly under ``/documents/``).
|
||||
|
|
@ -98,7 +98,7 @@ async def _ensure_folder_hierarchy(
|
|||
for raw in folder_parts:
|
||||
name = safe_folder_segment(str(raw))
|
||||
query = select(Folder).where(
|
||||
Folder.search_space_id == search_space_id,
|
||||
Folder.workspace_id == workspace_id,
|
||||
Folder.name == name,
|
||||
)
|
||||
if parent_id is None:
|
||||
|
|
@ -111,9 +111,7 @@ async def _ensure_folder_hierarchy(
|
|||
sibling_query = (
|
||||
select(Folder.position).order_by(Folder.position.desc()).limit(1)
|
||||
)
|
||||
sibling_query = sibling_query.where(
|
||||
Folder.search_space_id == search_space_id
|
||||
)
|
||||
sibling_query = sibling_query.where(Folder.workspace_id == workspace_id)
|
||||
if parent_id is None:
|
||||
sibling_query = sibling_query.where(Folder.parent_id.is_(None))
|
||||
else:
|
||||
|
|
@ -124,7 +122,7 @@ async def _ensure_folder_hierarchy(
|
|||
name=name,
|
||||
position=generate_key_between(last_position, None),
|
||||
parent_id=parent_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
|
@ -137,7 +135,7 @@ async def _ensure_folder_hierarchy(
|
|||
async def _resolve_folder_id(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
folder_parts: list[str],
|
||||
) -> int | None:
|
||||
"""Look up an existing folder chain without creating anything.
|
||||
|
|
@ -151,7 +149,7 @@ async def _resolve_folder_id(
|
|||
for raw in folder_parts:
|
||||
name = safe_folder_segment(str(raw))
|
||||
query = select(Folder).where(
|
||||
Folder.search_space_id == search_space_id,
|
||||
Folder.workspace_id == workspace_id,
|
||||
Folder.name == name,
|
||||
)
|
||||
query = (
|
||||
|
|
@ -185,7 +183,7 @@ async def _create_document(
|
|||
*,
|
||||
virtual_path: str,
|
||||
content: str,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str | None,
|
||||
) -> Document:
|
||||
"""Create a NOTE Document + Chunks for ``virtual_path``."""
|
||||
|
|
@ -194,21 +192,21 @@ async def _create_document(
|
|||
raise ValueError(f"invalid /documents path '{virtual_path}'")
|
||||
folder_id = await _ensure_folder_hierarchy(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
folder_parts=folder_parts,
|
||||
)
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.NOTE,
|
||||
virtual_path,
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
)
|
||||
# Pre-check the path-derived unique_identifier_hash so a duplicate path
|
||||
# surfaces as a clean ValueError instead of an INSERT IntegrityError that
|
||||
# poisons the session. Content is intentionally not unique (cp a b).
|
||||
path_collision = await session.execute(
|
||||
select(Document.id).where(
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
Document.unique_identifier_hash == unique_identifier_hash,
|
||||
)
|
||||
)
|
||||
|
|
@ -217,7 +215,7 @@ async def _create_document(
|
|||
f"a document already exists at path '{virtual_path}' "
|
||||
"(unique_identifier_hash collision)"
|
||||
)
|
||||
content_hash = generate_content_hash(content, search_space_id)
|
||||
content_hash = generate_content_hash(content, workspace_id)
|
||||
doc = Document(
|
||||
title=title,
|
||||
document_type=DocumentType.NOTE,
|
||||
|
|
@ -226,7 +224,7 @@ async def _create_document(
|
|||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
source_markdown=content,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
folder_id=folder_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_at=datetime.now(UTC),
|
||||
|
|
@ -261,13 +259,13 @@ async def _update_document(
|
|||
doc_id: int,
|
||||
content: str,
|
||||
virtual_path: str,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
) -> Document | None:
|
||||
"""Update an existing Document's content + chunks."""
|
||||
result = await session.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
document = result.scalar_one_or_none()
|
||||
|
|
@ -276,7 +274,7 @@ async def _update_document(
|
|||
|
||||
document.content = content
|
||||
document.source_markdown = content
|
||||
document.content_hash = generate_content_hash(content, search_space_id)
|
||||
document.content_hash = generate_content_hash(content, workspace_id)
|
||||
document.updated_at = datetime.now(UTC)
|
||||
metadata = dict(document.document_metadata or {})
|
||||
metadata["virtual_path"] = virtual_path
|
||||
|
|
@ -284,7 +282,7 @@ async def _update_document(
|
|||
document.unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.NOTE,
|
||||
virtual_path,
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
)
|
||||
|
||||
summary_embedding = (await asyncio.to_thread(embed_texts, [content]))[0]
|
||||
|
|
@ -318,7 +316,7 @@ async def _update_document(
|
|||
async def _apply_move(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str | None,
|
||||
move: dict[str, Any],
|
||||
doc_id_by_path: dict[str, int],
|
||||
|
|
@ -341,14 +339,14 @@ async def _apply_move(
|
|||
result = await session.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
document = result.scalar_one_or_none()
|
||||
if document is None:
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
virtual_path=source,
|
||||
)
|
||||
if document is None:
|
||||
|
|
@ -364,7 +362,7 @@ async def _apply_move(
|
|||
return None
|
||||
folder_id = await _ensure_folder_hierarchy(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
folder_parts=folder_parts,
|
||||
)
|
||||
|
|
@ -377,7 +375,7 @@ async def _apply_move(
|
|||
document.unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.NOTE,
|
||||
dest,
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
)
|
||||
document.updated_at = datetime.now(UTC)
|
||||
|
||||
|
|
@ -501,7 +499,7 @@ async def _snapshot_document_pre_write(
|
|||
*,
|
||||
doc: Document,
|
||||
action_id: int | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
turn_id: str | None = None,
|
||||
deferred_dispatches: list[int] | None = None,
|
||||
) -> int | None:
|
||||
|
|
@ -517,7 +515,7 @@ async def _snapshot_document_pre_write(
|
|||
payload = _doc_revision_payload(doc, chunks_before=chunks)
|
||||
rev = DocumentRevision(
|
||||
document_id=doc.id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_turn_id=turn_id,
|
||||
agent_action_id=action_id,
|
||||
**payload,
|
||||
|
|
@ -544,7 +542,7 @@ async def _snapshot_document_pre_create(
|
|||
session: AsyncSession,
|
||||
*,
|
||||
action_id: int | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
turn_id: str | None = None,
|
||||
deferred_dispatches: list[int] | None = None,
|
||||
) -> int | None:
|
||||
|
|
@ -558,7 +556,7 @@ async def _snapshot_document_pre_create(
|
|||
async with session.begin_nested():
|
||||
rev = DocumentRevision(
|
||||
document_id=None,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
content_before=None,
|
||||
title_before=None,
|
||||
folder_id_before=None,
|
||||
|
|
@ -586,7 +584,7 @@ async def _snapshot_document_pre_move(
|
|||
*,
|
||||
doc: Document,
|
||||
action_id: int | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
turn_id: str | None = None,
|
||||
deferred_dispatches: list[int] | None = None,
|
||||
) -> int | None:
|
||||
|
|
@ -596,7 +594,7 @@ async def _snapshot_document_pre_move(
|
|||
payload = _doc_revision_payload(doc, chunks_before=None)
|
||||
rev = DocumentRevision(
|
||||
document_id=doc.id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_turn_id=turn_id,
|
||||
agent_action_id=action_id,
|
||||
**payload,
|
||||
|
|
@ -624,7 +622,7 @@ async def _snapshot_folder_pre_mkdir(
|
|||
*,
|
||||
folder: Folder,
|
||||
action_id: int | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
turn_id: str | None = None,
|
||||
deferred_dispatches: list[int] | None = None,
|
||||
) -> int | None:
|
||||
|
|
@ -637,7 +635,7 @@ async def _snapshot_folder_pre_mkdir(
|
|||
async with session.begin_nested():
|
||||
rev = FolderRevision(
|
||||
folder_id=folder.id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
name_before=None,
|
||||
parent_id_before=None,
|
||||
position_before=None,
|
||||
|
|
@ -670,7 +668,7 @@ async def _snapshot_folder_pre_mkdir(
|
|||
async def commit_staged_filesystem_state(
|
||||
state: dict[str, Any] | AgentState,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str | None,
|
||||
filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
|
||||
thread_id: int | None = None,
|
||||
|
|
@ -814,7 +812,7 @@ async def commit_staged_filesystem_state(
|
|||
continue
|
||||
folder_id = await _ensure_folder_hierarchy(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
folder_parts=folder_parts_full,
|
||||
)
|
||||
|
|
@ -833,7 +831,7 @@ async def commit_staged_filesystem_state(
|
|||
session,
|
||||
folder=folder_row,
|
||||
action_id=action_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
turn_id=tcid,
|
||||
deferred_dispatches=deferred_dispatches,
|
||||
)
|
||||
|
|
@ -851,14 +849,14 @@ async def commit_staged_filesystem_state(
|
|||
res_pre = await session.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id_pre,
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
document_pre = res_pre.scalar_one_or_none()
|
||||
if document_pre is None:
|
||||
document_pre = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
virtual_path=source,
|
||||
)
|
||||
if document_pre is not None:
|
||||
|
|
@ -866,14 +864,14 @@ async def commit_staged_filesystem_state(
|
|||
session,
|
||||
doc=document_pre,
|
||||
action_id=action_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
turn_id=tcid,
|
||||
deferred_dispatches=deferred_dispatches,
|
||||
)
|
||||
|
||||
applied = await _apply_move(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
move=move,
|
||||
doc_id_by_path=doc_id_by_path,
|
||||
|
|
@ -937,7 +935,7 @@ async def commit_staged_filesystem_state(
|
|||
# INSERT (which would hit the path-derived unique hash).
|
||||
existing = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
virtual_path=path,
|
||||
)
|
||||
if existing is not None:
|
||||
|
|
@ -948,7 +946,7 @@ async def commit_staged_filesystem_state(
|
|||
result_doc = await session.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
existing_doc = result_doc.scalar_one_or_none()
|
||||
|
|
@ -957,7 +955,7 @@ async def commit_staged_filesystem_state(
|
|||
session,
|
||||
doc=existing_doc,
|
||||
action_id=action_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
turn_id=tcid,
|
||||
deferred_dispatches=deferred_dispatches,
|
||||
)
|
||||
|
|
@ -966,7 +964,7 @@ async def commit_staged_filesystem_state(
|
|||
doc_id=doc_id,
|
||||
content=content,
|
||||
virtual_path=path,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
if updated is not None:
|
||||
committed_updates.append(
|
||||
|
|
@ -974,7 +972,7 @@ async def commit_staged_filesystem_state(
|
|||
"id": updated.id,
|
||||
"title": updated.title,
|
||||
"documentType": DocumentType.NOTE.value,
|
||||
"searchSpaceId": search_space_id,
|
||||
"workspaceId": workspace_id,
|
||||
"folderId": updated.folder_id,
|
||||
"createdById": str(created_by_id)
|
||||
if created_by_id
|
||||
|
|
@ -991,7 +989,7 @@ async def commit_staged_filesystem_state(
|
|||
placeholder_revision_id = await _snapshot_document_pre_create(
|
||||
session,
|
||||
action_id=action_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
turn_id=tcid,
|
||||
deferred_dispatches=deferred_dispatches,
|
||||
)
|
||||
|
|
@ -1001,7 +999,7 @@ async def commit_staged_filesystem_state(
|
|||
session,
|
||||
virtual_path=path,
|
||||
content=content,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
@ -1045,7 +1043,7 @@ async def commit_staged_filesystem_state(
|
|||
"id": new_doc.id,
|
||||
"title": new_doc.title,
|
||||
"documentType": DocumentType.NOTE.value,
|
||||
"searchSpaceId": search_space_id,
|
||||
"workspaceId": workspace_id,
|
||||
"folderId": new_doc.folder_id,
|
||||
"createdById": str(created_by_id)
|
||||
if created_by_id
|
||||
|
|
@ -1069,14 +1067,14 @@ async def commit_staged_filesystem_state(
|
|||
result = await session.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id_for_delete,
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
document_to_delete = result.scalar_one_or_none()
|
||||
if document_to_delete is None:
|
||||
document_to_delete = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
virtual_path=final,
|
||||
)
|
||||
if document_to_delete is None:
|
||||
|
|
@ -1100,7 +1098,7 @@ async def commit_staged_filesystem_state(
|
|||
)
|
||||
rev = DocumentRevision(
|
||||
document_id=doc_pk,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_turn_id=tcid,
|
||||
agent_action_id=action_id,
|
||||
**payload,
|
||||
|
|
@ -1130,7 +1128,7 @@ async def commit_staged_filesystem_state(
|
|||
"id": doc_pk,
|
||||
"title": doc_title,
|
||||
"documentType": DocumentType.NOTE.value,
|
||||
"searchSpaceId": search_space_id,
|
||||
"workspaceId": workspace_id,
|
||||
"folderId": doc_folder_id,
|
||||
"createdById": str(created_by_id) if created_by_id else None,
|
||||
"virtualPath": final,
|
||||
|
|
@ -1151,7 +1149,7 @@ async def commit_staged_filesystem_state(
|
|||
continue
|
||||
folder_id = await _resolve_folder_id(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
folder_parts=folder_parts,
|
||||
)
|
||||
if folder_id is None:
|
||||
|
|
@ -1163,7 +1161,7 @@ async def commit_staged_filesystem_state(
|
|||
docs_in_folder = await session.execute(
|
||||
select(Document.id)
|
||||
.where(Document.folder_id == folder_id)
|
||||
.where(Document.search_space_id == search_space_id)
|
||||
.where(Document.workspace_id == workspace_id)
|
||||
.limit(1)
|
||||
)
|
||||
if docs_in_folder.scalar_one_or_none() is not None:
|
||||
|
|
@ -1175,7 +1173,7 @@ async def commit_staged_filesystem_state(
|
|||
child_folders = await session.execute(
|
||||
select(Folder.id)
|
||||
.where(Folder.parent_id == folder_id)
|
||||
.where(Folder.search_space_id == search_space_id)
|
||||
.where(Folder.workspace_id == workspace_id)
|
||||
.limit(1)
|
||||
)
|
||||
if child_folders.scalar_one_or_none() is not None:
|
||||
|
|
@ -1203,7 +1201,7 @@ async def commit_staged_filesystem_state(
|
|||
if snapshot_enabled and action_id is not None:
|
||||
rev = FolderRevision(
|
||||
folder_id=folder_pk,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
name_before=folder_name,
|
||||
parent_id_before=folder_parent_id,
|
||||
position_before=folder_position,
|
||||
|
|
@ -1232,7 +1230,7 @@ async def commit_staged_filesystem_state(
|
|||
{
|
||||
"id": folder_pk,
|
||||
"name": folder_name,
|
||||
"searchSpaceId": search_space_id,
|
||||
"workspaceId": workspace_id,
|
||||
"parentId": folder_parent_id,
|
||||
"virtualPath": final,
|
||||
}
|
||||
|
|
@ -1241,9 +1239,7 @@ async def commit_staged_filesystem_state(
|
|||
|
||||
await session.commit()
|
||||
except Exception: # pragma: no cover - rollback safety net
|
||||
logger.exception(
|
||||
"kb_persistence: commit failed (search_space=%s)", search_space_id
|
||||
)
|
||||
logger.exception("kb_persistence: commit failed (workspace=%s)", workspace_id)
|
||||
# Outer commit raised: everything above rolled back, so drop the
|
||||
# deferred dispatches.
|
||||
deferred_dispatches.clear()
|
||||
|
|
@ -1402,9 +1398,9 @@ async def commit_staged_filesystem_state(
|
|||
_ = turn_id_for_revision # diagnostic-only; silence unused lint
|
||||
|
||||
logger.info(
|
||||
"kb_persistence: commit (search_space=%s) creates=%d updates=%d "
|
||||
"kb_persistence: commit (workspace=%s) creates=%d updates=%d "
|
||||
"moves=%d staged_dirs=%d deletes=%d folder_deletes=%d discarded=%d",
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
len(committed_creates),
|
||||
len(committed_updates),
|
||||
len(applied_moves),
|
||||
|
|
@ -1430,12 +1426,12 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type-
|
|||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str | None,
|
||||
filesystem_mode: FilesystemMode,
|
||||
thread_id: int | None = None,
|
||||
) -> None:
|
||||
self.search_space_id = search_space_id
|
||||
self.workspace_id = workspace_id
|
||||
self.created_by_id = created_by_id
|
||||
self.filesystem_mode = filesystem_mode
|
||||
self.thread_id = thread_id
|
||||
|
|
@ -1450,7 +1446,7 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type-
|
|||
return None
|
||||
return await commit_staged_filesystem_state(
|
||||
state,
|
||||
search_space_id=self.search_space_id,
|
||||
workspace_id=self.workspace_id,
|
||||
created_by_id=self.created_by_id,
|
||||
filesystem_mode=self.filesystem_mode,
|
||||
thread_id=self._resolve_thread_id(),
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ from .middleware import KnowledgeTreeMiddleware
|
|||
def build_knowledge_tree_mw(
|
||||
*,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
llm: BaseChatModel,
|
||||
) -> KnowledgeTreeMiddleware | None:
|
||||
if filesystem_mode != FilesystemMode.CLOUD:
|
||||
return None
|
||||
return KnowledgeTreeMiddleware(
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
filesystem_mode=filesystem_mode,
|
||||
llm=llm,
|
||||
inject_system_message=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Workspace-tree middleware for the SurfSense agent.
|
||||
|
||||
Renders the full ``Folder``+``Document`` tree under ``/documents/`` once per
|
||||
turn (cloud only), caches it by ``(search_space_id, tree_version)``, and
|
||||
turn (cloud only), caches it by ``(workspace_id, tree_version)``, and
|
||||
injects the result as a ``<workspace_tree>`` system message immediately
|
||||
before the latest human turn.
|
||||
|
||||
|
|
@ -106,14 +106,14 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
filesystem_mode: FilesystemMode,
|
||||
llm: BaseChatModel | None = None,
|
||||
max_entries: int = MAX_TREE_ENTRIES,
|
||||
max_tokens: int = MAX_TREE_TOKENS,
|
||||
inject_system_message: bool = True, # For backwards compatibility
|
||||
) -> None:
|
||||
self.search_space_id = search_space_id
|
||||
self.workspace_id = workspace_id
|
||||
self.filesystem_mode = filesystem_mode
|
||||
self.llm = llm
|
||||
self.max_entries = max_entries
|
||||
|
|
@ -141,7 +141,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
cache_outcome = "anon"
|
||||
else:
|
||||
version = int(state.get("tree_version") or 0)
|
||||
cache_key = (self.search_space_id, version, False)
|
||||
cache_key = (self.workspace_id, version, False)
|
||||
cache_outcome = "hit" if cache_key in self._cache else "miss"
|
||||
tree_msg = await self._render_kb_tree(state)
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
cache_outcome,
|
||||
len(tree_msg),
|
||||
time.perf_counter() - start,
|
||||
self.search_space_id,
|
||||
self.workspace_id,
|
||||
)
|
||||
return update
|
||||
|
||||
|
|
@ -190,17 +190,17 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
|
||||
async def _render_kb_tree(self, state: AgentState) -> str:
|
||||
version = int(state.get("tree_version") or 0)
|
||||
cache_key = (self.search_space_id, version, False)
|
||||
cache_key = (self.workspace_id, version, False)
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with shielded_async_session() as session:
|
||||
index = await build_path_index(session, self.search_space_id)
|
||||
index = await build_path_index(session, self.workspace_id)
|
||||
doc_rows = await session.execute(
|
||||
select(Document.id, Document.title, Document.folder_id).where(
|
||||
Document.search_space_id == self.search_space_id
|
||||
Document.workspace_id == self.workspace_id
|
||||
)
|
||||
)
|
||||
docs = list(doc_rows.all())
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ from .middleware import MemoryInjectionMiddleware
|
|||
def build_memory_mw(
|
||||
*,
|
||||
user_id: str | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
visibility: ChatVisibility,
|
||||
) -> MemoryInjectionMiddleware:
|
||||
return MemoryInjectionMiddleware(
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_visibility=visibility,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from langgraph.runtime import Runtime
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
|
||||
from app.db import ChatVisibility, User, Workspace, shielded_async_session
|
||||
from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
|
||||
from app.utils.perf import get_perf_logger
|
||||
|
||||
|
|
@ -35,11 +35,11 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
self,
|
||||
*,
|
||||
user_id: str | UUID | None,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
thread_visibility: ChatVisibility | None = None,
|
||||
) -> None:
|
||||
self.user_id = UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
self.search_space_id = search_space_id
|
||||
self.workspace_id = workspace_id
|
||||
self.visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||
|
||||
async def abefore_agent( # type: ignore[override]
|
||||
|
|
@ -149,8 +149,8 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
async def _load_team_memory(self, session: AsyncSession) -> str | None:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(SearchSpace.shared_memory_md).where(
|
||||
SearchSpace.id == self.search_space_id
|
||||
select(Workspace.shared_memory_md).where(
|
||||
Workspace.id == self.workspace_id
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from ..plugins.loader import (
|
|||
def build_plugin_middlewares(
|
||||
*,
|
||||
flags: AgentFeatureFlags,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
visibility: ChatVisibility,
|
||||
llm: BaseChatModel,
|
||||
|
|
@ -34,7 +34,7 @@ def build_plugin_middlewares(
|
|||
return []
|
||||
return load_plugin_middlewares(
|
||||
PluginContext.build(
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
thread_visibility=visibility,
|
||||
llm=llm,
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ def build_skills_mw(
|
|||
*,
|
||||
flags: AgentFeatureFlags,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
) -> SkillsMiddleware | None:
|
||||
if not enabled(flags, "enable_skills"):
|
||||
return None
|
||||
try:
|
||||
skills_factory = build_skills_backend_factory(
|
||||
search_space_id=search_space_id
|
||||
workspace_id=workspace_id
|
||||
if filesystem_mode == FilesystemMode.CLOUD
|
||||
else None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ retrieval are owned by the ``knowledge_base`` subagent and reached via the
|
|||
the main agent only sees the specialist's grounded summary. The stack here
|
||||
computes the workspace tree, commits any subagent-side staged writes at end of
|
||||
turn (cloud mode), and wires the supporting middleware.
|
||||
|
||||
One deliberate, read-only exception to the pure-router stance: the main agent
|
||||
also carries the ``read_run``/``search_run`` tools (added in ``runtime/factory``)
|
||||
so it can follow the context-editing spill placeholder — evicted tool output is
|
||||
persisted to ``tool_output_spills`` and the placeholder advertises those tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -99,7 +104,7 @@ def build_main_agent_deepagent_middleware(
|
|||
tools: Sequence[BaseTool],
|
||||
backend_resolver: Any,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
thread_id: int | None,
|
||||
visibility: ChatVisibility,
|
||||
|
|
@ -120,7 +125,7 @@ def build_main_agent_deepagent_middleware(
|
|||
|
||||
memory_mw = build_memory_mw(
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
visibility=visibility,
|
||||
)
|
||||
|
||||
|
|
@ -232,19 +237,19 @@ def build_main_agent_deepagent_middleware(
|
|||
),
|
||||
build_knowledge_tree_mw(
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
llm=llm,
|
||||
),
|
||||
build_kb_persistence_mw(
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
thread_id=thread_id,
|
||||
),
|
||||
build_skills_mw(
|
||||
flags=flags,
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
),
|
||||
SurfSenseCheckpointedSubAgentMiddleware(
|
||||
checkpointer=checkpointer,
|
||||
|
|
@ -252,7 +257,7 @@ def build_main_agent_deepagent_middleware(
|
|||
subagents=subagents,
|
||||
system_prompt=None,
|
||||
task_description=TASK_TOOL_DESCRIPTION,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
),
|
||||
resilience.model_call_limit,
|
||||
resilience.tool_call_limit,
|
||||
|
|
@ -260,7 +265,7 @@ def build_main_agent_deepagent_middleware(
|
|||
flags=flags,
|
||||
max_input_tokens=max_input_tokens,
|
||||
tools=tools,
|
||||
backend_resolver=backend_resolver,
|
||||
workspace_id=workspace_id,
|
||||
),
|
||||
build_compaction_mw(llm),
|
||||
build_noop_injection_mw(flags),
|
||||
|
|
@ -272,14 +277,14 @@ def build_main_agent_deepagent_middleware(
|
|||
build_action_log_mw(
|
||||
flags=flags,
|
||||
thread_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
),
|
||||
build_patch_tool_calls_mw(),
|
||||
build_dedup_hitl_mw(tools),
|
||||
*build_plugin_middlewares(
|
||||
flags=flags,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
visibility=visibility,
|
||||
llm=llm,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class PluginContext(dict):
|
|||
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)
|
||||
* ``workspace_id`` (int)
|
||||
* ``user_id`` (str | None)
|
||||
* ``thread_visibility`` (:class:`app.db.ChatVisibility`)
|
||||
* ``llm`` (:class:`langchain_core.language_models.BaseChatModel`)
|
||||
|
|
@ -72,13 +72,13 @@ class PluginContext(dict):
|
|||
def build(
|
||||
cls,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
thread_visibility: ChatVisibility,
|
||||
llm: BaseChatModel,
|
||||
) -> PluginContext:
|
||||
return cls(
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
thread_visibility=thread_visibility,
|
||||
llm=llm,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ result. This particular plugin is read-only and only transforms the
|
|||
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
|
||||
inject :class:`PluginContext` (containing the agent's LLM, workspace
|
||||
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
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ async def build_agent_with_cache(
|
|||
final_system_prompt: str,
|
||||
backend_resolver: Any,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
thread_id: int | None,
|
||||
visibility: ChatVisibility,
|
||||
|
|
@ -69,7 +69,7 @@ async def build_agent_with_cache(
|
|||
final_system_prompt=final_system_prompt,
|
||||
backend_resolver=backend_resolver,
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
thread_id=thread_id,
|
||||
visibility=visibility,
|
||||
|
|
@ -104,7 +104,7 @@ async def build_agent_with_cache(
|
|||
config_id,
|
||||
None if cross_thread else thread_id,
|
||||
user_id,
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
visibility,
|
||||
filesystem_mode,
|
||||
anon_session_id,
|
||||
|
|
@ -120,7 +120,7 @@ async def build_agent_with_cache(
|
|||
sorted(disabled_tools) if disabled_tools else None,
|
||||
# Bound into the generate_image subagent tool at construction time, so it
|
||||
# must key the compiled-agent cache to avoid leaking one automation's
|
||||
# image model into another with the same config_id/search_space.
|
||||
# image model into another with the same config_id/workspace.
|
||||
image_gen_model_id_override,
|
||||
)
|
||||
return await get_cache().get_or_build(cache_key, builder=_build)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Why a per-thread key (not a global pool)
|
|||
----------------------------------------
|
||||
|
||||
Most middleware in the SurfSense stack captures per-thread state in
|
||||
``__init__`` closures (``thread_id``, ``user_id``, ``search_space_id``,
|
||||
``__init__`` closures (``thread_id``, ``user_id``, ``workspace_id``,
|
||||
``filesystem_mode``, ``mentioned_document_ids``). Cross-thread reuse
|
||||
would silently leak state across users and threads. Keying the cache on
|
||||
``(llm_config_id, thread_id, ...)`` gives us safe reuse for repeated
|
||||
|
|
@ -34,7 +34,7 @@ turns on the same thread without changing any middleware's behavior.
|
|||
|
||||
Phase 2 will move those captured fields onto :class:`SurfSenseContextSchema`
|
||||
(read via ``runtime.context``) so the cache can collapse to a single
|
||||
``(llm_config_id, search_space_id, ...)`` key shared across threads. Until
|
||||
``(llm_config_id, workspace_id, ...)`` key shared across threads. Until
|
||||
then, per-thread keying is the only safe option.
|
||||
|
||||
Cache shape
|
||||
|
|
@ -111,7 +111,7 @@ def tools_signature(
|
|||
|
||||
* A tool is added or removed from the bound list (built-in toggles,
|
||||
MCP tools loaded for the user changes, gating rules flip, etc.).
|
||||
* The available connectors / document types for the search space
|
||||
* The available connectors / document types for the workspace
|
||||
change (new connector added, last connector removed, new document
|
||||
type indexed). Connector gating derives disabled tools from
|
||||
``available_connectors``, so the tool surface is technically already
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""Map configured connectors to the searchable document/connector types.
|
||||
|
||||
This is agent-agnostic infrastructure shared by every agent factory (single-
|
||||
and multi-agent). It translates the connectors a search space has enabled into
|
||||
the set of searchable type strings that pre-search middleware and ``web_search``
|
||||
understand, and always layers in the document types that exist independently of
|
||||
any connector (uploads, notes, extension captures, YouTube).
|
||||
and multi-agent). It translates the connectors a workspace has enabled into
|
||||
the set of searchable type strings that pre-search middleware understands, and
|
||||
always layers in the document types that exist independently of any connector
|
||||
(uploads, notes, extension captures, YouTube).
|
||||
|
||||
It lives in its own module — rather than inside a specific agent factory — so
|
||||
that retiring or moving any single agent never disturbs the others' access to
|
||||
|
|
@ -16,15 +16,8 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
|
||||
# Maps SearchSourceConnectorType enum values to the searchable document/connector types
|
||||
# used by pre-search middleware and web_search.
|
||||
# Live search connectors (TAVILY_API, LINKUP_API, BAIDU_SEARCH_API) are routed to
|
||||
# the web_search tool; all others are considered local/indexed data.
|
||||
# used by KB pre-search middleware. All entries are local/indexed data.
|
||||
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
|
||||
# Live search connectors (handled by web_search tool)
|
||||
"TAVILY_API": "TAVILY_API",
|
||||
"LINKUP_API": "LINKUP_API",
|
||||
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
|
||||
# Local/indexed connectors (handled by KB pre-search middleware)
|
||||
"SLACK_CONNECTOR": "SLACK_CONNECTOR",
|
||||
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
|
||||
"NOTION_CONNECTOR": "NOTION_CONNECTOR",
|
||||
|
|
@ -40,12 +33,14 @@ _CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
|
|||
"AIRTABLE_CONNECTOR": "AIRTABLE_CONNECTOR",
|
||||
"LUMA_CONNECTOR": "LUMA_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR": "ELASTICSEARCH_CONNECTOR",
|
||||
"WEBCRAWLER_CONNECTOR": "CRAWLED_URL", # Maps to document type
|
||||
"BOOKSTACK_CONNECTOR": "BOOKSTACK_CONNECTOR",
|
||||
"CIRCLEBACK_CONNECTOR": "CIRCLEBACK", # Connector type differs from document type
|
||||
"OBSIDIAN_CONNECTOR": "OBSIDIAN_CONNECTOR",
|
||||
"DROPBOX_CONNECTOR": "DROPBOX_FILE", # Connector type differs from document type
|
||||
"ONEDRIVE_CONNECTOR": "ONEDRIVE_FILE", # Connector type differs from document type
|
||||
# Generic user-defined MCP server: unlocks the mcp_discovery subagent even
|
||||
# in a workspace with no hosted-service connectors.
|
||||
"MCP_CONNECTOR": "MCP_CONNECTOR",
|
||||
# Composio connectors (unified to native document types).
|
||||
# Reverse of NATIVE_TO_LEGACY_DOCTYPE in app.db.
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE",
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ _perf_log = get_perf_logger()
|
|||
|
||||
async def create_multi_agent_chat_deep_agent(
|
||||
llm: BaseChatModel,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
db_session: AsyncSession,
|
||||
connector_service: ConnectorService,
|
||||
checkpointer: Checkpointer,
|
||||
|
|
@ -68,7 +68,6 @@ async def create_multi_agent_chat_deep_agent(
|
|||
enabled_tools: list[str] | None = None,
|
||||
disabled_tools: list[str] | None = None,
|
||||
additional_tools: Sequence[BaseTool] | None = None,
|
||||
firecrawl_api_key: str | None = None,
|
||||
thread_visibility: ChatVisibility | None = None,
|
||||
mentioned_document_ids: list[int] | None = None,
|
||||
anon_session_id: str | None = None,
|
||||
|
|
@ -78,9 +77,9 @@ async def create_multi_agent_chat_deep_agent(
|
|||
):
|
||||
"""Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.
|
||||
|
||||
``image_gen_model_id`` overrides the search space's image model for
|
||||
``image_gen_model_id`` overrides the workspace's image model for
|
||||
this invocation (used by automations to run on their captured model). When
|
||||
``None``, the ``generate_image`` tool resolves the live search-space pref.
|
||||
``None``, the ``generate_image`` tool resolves the live workspace pref.
|
||||
"""
|
||||
_t_agent_total = time.perf_counter()
|
||||
|
||||
|
|
@ -89,7 +88,7 @@ async def create_multi_agent_chat_deep_agent(
|
|||
filesystem_selection = filesystem_selection or FilesystemSelection()
|
||||
backend_resolver = build_backend_resolver(
|
||||
filesystem_selection,
|
||||
search_space_id=search_space_id
|
||||
workspace_id=workspace_id
|
||||
if filesystem_selection.mode == FilesystemMode.CLOUD
|
||||
else None,
|
||||
)
|
||||
|
|
@ -99,13 +98,11 @@ async def create_multi_agent_chat_deep_agent(
|
|||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
connector_types = await connector_service.get_available_connectors(
|
||||
search_space_id
|
||||
)
|
||||
connector_types = await connector_service.get_available_connectors(workspace_id)
|
||||
available_connectors = map_connectors_to_searchable_types(connector_types)
|
||||
|
||||
available_document_types = await connector_service.get_available_document_types(
|
||||
search_space_id
|
||||
workspace_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -136,10 +133,9 @@ async def create_multi_agent_chat_deep_agent(
|
|||
)
|
||||
|
||||
dependencies: dict[str, Any] = {
|
||||
"search_space_id": search_space_id,
|
||||
"workspace_id": workspace_id,
|
||||
"db_session": db_session,
|
||||
"connector_service": connector_service,
|
||||
"firecrawl_api_key": firecrawl_api_key,
|
||||
"user_id": user_id,
|
||||
"auth_context": auth_context,
|
||||
"thread_id": thread_id,
|
||||
|
|
@ -155,9 +151,7 @@ async def create_multi_agent_chat_deep_agent(
|
|||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
mcp_tools_by_agent = await load_mcp_tools_by_connector(
|
||||
db_session, search_space_id
|
||||
)
|
||||
mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, workspace_id)
|
||||
except Exception as e:
|
||||
# Degrade to builtins-only rather than aborting the turn: a transient
|
||||
# DB or MCP-server hiccup should not deny the user a response.
|
||||
|
|
@ -193,7 +187,7 @@ async def create_multi_agent_chat_deep_agent(
|
|||
user_allowlist_by_subagent = await fetch_user_allowlist_rulesets(
|
||||
db_session,
|
||||
user_id=user_uuid,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
|
|
@ -230,6 +224,15 @@ async def create_multi_agent_chat_deep_agent(
|
|||
additional_tools=list(additional_tools) if additional_tools else None,
|
||||
)
|
||||
|
||||
# Read-only exception to the "main agent is a pure router" stance: the
|
||||
# context-editing spill placeholder points at read_run/search_run, so the
|
||||
# main agent needs those tools to follow it. See middleware/stack.py.
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.run_reader import (
|
||||
build_run_reader_tools,
|
||||
)
|
||||
|
||||
tools = [*list(tools), *build_run_reader_tools(workspace_id=workspace_id)]
|
||||
|
||||
_flags: AgentFeatureFlags = get_flags()
|
||||
if _flags.enable_tool_call_repair and INVALID_TOOL_NAME not in {
|
||||
t.name for t in tools
|
||||
|
|
@ -291,7 +294,7 @@ async def create_multi_agent_chat_deep_agent(
|
|||
final_system_prompt=final_system_prompt,
|
||||
backend_resolver=backend_resolver,
|
||||
filesystem_mode=filesystem_selection.mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
thread_id=thread_id,
|
||||
visibility=visibility,
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ Two backends are provided:
|
|||
|
||||
* :class:`BuiltinSkillsBackend` — disk-backed read of bundled skills from
|
||||
``app/agents/shared/skills/builtin/``.
|
||||
* :class:`SearchSpaceSkillsBackend` — a thin read-only wrapper over
|
||||
* :class:`WorkspaceSkillsBackend` — 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
|
||||
(via filesystem or a workspace-admin route), so we never expose
|
||||
``write`` / ``edit`` / ``upload_files``. The base class' ``NotImplementedError``
|
||||
gives a clean failure mode if anything tries.
|
||||
"""
|
||||
|
|
@ -181,12 +181,12 @@ class BuiltinSkillsBackend(BackendProtocol):
|
|||
return responses
|
||||
|
||||
|
||||
class SearchSpaceSkillsBackend(BackendProtocol):
|
||||
"""Read-only view of search-space-authored skills.
|
||||
class WorkspaceSkillsBackend(BackendProtocol):
|
||||
"""Read-only view of workspace-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.
|
||||
writable only by workspace admins; this backend never writes.
|
||||
|
||||
The skills middleware expects a layout like::
|
||||
|
||||
|
|
@ -236,14 +236,14 @@ class SearchSpaceSkillsBackend(BackendProtocol):
|
|||
# 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")
|
||||
raise NotImplementedError("WorkspaceSkillsBackend 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)
|
||||
logger.warning("WorkspaceSkillsBackend.als_info failed: %s", exc)
|
||||
return []
|
||||
remapped: list[FileInfo] = []
|
||||
for info in infos:
|
||||
|
|
@ -254,7 +254,7 @@ class SearchSpaceSkillsBackend(BackendProtocol):
|
|||
return remapped
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
|
||||
raise NotImplementedError("WorkspaceSkillsBackend is async-only")
|
||||
|
||||
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
kb_paths = [self._to_kb(p) for p in paths]
|
||||
|
|
@ -274,16 +274,16 @@ SKILLS_SPACE_PREFIX = "/skills/space/"
|
|||
def build_skills_backend_factory(
|
||||
*,
|
||||
builtin_root: Path | str | None = None,
|
||||
search_space_id: int | None = None,
|
||||
workspace_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
|
||||
When ``workspace_id`` is provided the composite includes a
|
||||
:class:`WorkspaceSkillsBackend` 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
|
||||
When ``workspace_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
|
||||
|
|
@ -293,7 +293,7 @@ def build_skills_backend_factory(
|
|||
"""
|
||||
builtin = BuiltinSkillsBackend(builtin_root)
|
||||
|
||||
if search_space_id is None:
|
||||
if workspace_id is None:
|
||||
|
||||
def _factory_builtin_only(runtime: ToolRuntime) -> BackendProtocol:
|
||||
# Default StateBackend is intentionally inert: any path outside the
|
||||
|
|
@ -314,8 +314,8 @@ def build_skills_backend_factory(
|
|||
KBPostgresBackend,
|
||||
)
|
||||
|
||||
kb = KBPostgresBackend(search_space_id, runtime)
|
||||
space = SearchSpaceSkillsBackend(kb)
|
||||
kb = KBPostgresBackend(workspace_id, runtime)
|
||||
space = WorkspaceSkillsBackend(kb)
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={
|
||||
|
|
@ -336,7 +336,7 @@ __all__ = [
|
|||
"SKILLS_BUILTIN_PREFIX",
|
||||
"SKILLS_SPACE_PREFIX",
|
||||
"BuiltinSkillsBackend",
|
||||
"SearchSpaceSkillsBackend",
|
||||
"WorkspaceSkillsBackend",
|
||||
"build_skills_backend_factory",
|
||||
"default_skills_sources",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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
|
||||
allowed-tools: read_file, ls_tree, grep
|
||||
---
|
||||
|
||||
# Knowledge-base research
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: meeting-prep
|
||||
description: Pull together briefing materials before a scheduled meeting
|
||||
allowed-tools: web_search, scrape_webpage, read_file
|
||||
allowed-tools: task, read_file
|
||||
---
|
||||
|
||||
# Meeting preparation
|
||||
|
|
@ -12,11 +12,11 @@ The user mentions an upcoming meeting, call, or interview and asks you to "prep"
|
|||
## 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.
|
||||
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 `task(google_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.
|
||||
- Only fall back to `task(google_search, ...)` for *publicly verifiable* facts — never to fabricate a participant's preferences or relationships.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<citations>
|
||||
Cite with one token: the bracket label `[n]`. Every citable result —
|
||||
`web_search` results and prose from a `task` knowledge_base/research
|
||||
specialist (including the knowledge_base specialist's `[n]`-labelled
|
||||
workspace findings) — already carries `[n]` labels on a single shared count.
|
||||
prose from a `task` knowledge_base/research specialist (including the
|
||||
knowledge_base specialist's `[n]`-labelled workspace findings) — already
|
||||
carries `[n]` labels on a single shared count.
|
||||
Those labels are the only citation you write; the server resolves each one
|
||||
back to its source after the turn.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
<agent_identity>
|
||||
You are **SurfSense's main agent**. Your job is to answer the user using their
|
||||
knowledge base, lightweight web research, persistent memory, and **specialist
|
||||
subagents** invoked via the `task` tool. You are an orchestrator — most
|
||||
non-trivial work belongs on a specialist.
|
||||
You are **SurfSense's main agent**, the orchestrator of an open-source
|
||||
competitive intelligence platform. Users come to you to understand their
|
||||
market: what competitors are doing, how audiences react, where rankings and
|
||||
reviews are moving, and what is being said across the open web — and to put
|
||||
that intelligence to work alongside their own knowledge base.
|
||||
|
||||
You do this by dispatching **specialist subagents** via the `task` tool:
|
||||
- **Live market data** — Reddit, YouTube, Google Maps, Google Search, and the
|
||||
web crawler return structured, current platform data (posts, comments,
|
||||
transcripts, reviews, SERPs, full page content).
|
||||
- **The user's own context** — their knowledge base, connected apps, and
|
||||
persistent memory.
|
||||
- **Deliverables** — reports, podcasts, and presentations built from what the
|
||||
specialists find.
|
||||
|
||||
You are an orchestrator — most non-trivial work belongs on a specialist. Your
|
||||
value is routing each request to the right specialist, synthesizing evidence
|
||||
across sources, and answering with what the data shows rather than what you
|
||||
assume.
|
||||
|
||||
Today (UTC): {resolved_today}
|
||||
</agent_identity>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
<agent_identity>
|
||||
You are **SurfSense's main agent**. Your job is to answer the user using their
|
||||
shared team knowledge base, lightweight web research, persistent memory, and
|
||||
**specialist subagents** invoked via the `task` tool. You are an orchestrator
|
||||
— most non-trivial work belongs on a specialist.
|
||||
You are **SurfSense's main agent**, the orchestrator of an open-source
|
||||
competitive intelligence platform. This team comes to you to understand its
|
||||
market: what competitors are doing, how audiences react, where rankings and
|
||||
reviews are moving, and what is being said across the open web — and to put
|
||||
that intelligence to work alongside the team's shared knowledge base.
|
||||
|
||||
You do this by dispatching **specialist subagents** via the `task` tool:
|
||||
- **Live market data** — Reddit, YouTube, Google Maps, Google Search, and the
|
||||
web crawler return structured, current platform data (posts, comments,
|
||||
transcripts, reviews, SERPs, full page content).
|
||||
- **The team's own context** — its shared knowledge base, connected apps, and
|
||||
persistent team memory.
|
||||
- **Deliverables** — reports, podcasts, and presentations built from what the
|
||||
specialists find.
|
||||
|
||||
You are an orchestrator — most non-trivial work belongs on a specialist. Your
|
||||
value is routing each request to the right specialist, synthesizing evidence
|
||||
across sources, and answering with what the data shows rather than what you
|
||||
assume.
|
||||
|
||||
Today (UTC): {resolved_today}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
<knowledge_base_first>
|
||||
CRITICAL — ground factual answers in what you actually receive this turn:
|
||||
- **live platform data** via the market specialists —
|
||||
`task(reddit, ...)`, `task(youtube, ...)`, `task(google_maps, ...)`,
|
||||
`task(google_search, ...)`, `task(web_crawler, ...)`. Anything about
|
||||
competitors, markets, rankings, reviews, or audience sentiment is answered
|
||||
from what these return **this turn**, never from your training data: your
|
||||
general knowledge of companies, prices, and rankings is stale by definition,
|
||||
- the user's knowledge base via `task(knowledge_base, ...)` (your PRIMARY
|
||||
source for anything about their documents, notes, or connected data — the
|
||||
`<workspace_tree>` only lists what exists, so delegate to the specialist to
|
||||
search and read the actual content before answering),
|
||||
source for anything about their own uploaded files, documents, and notes —
|
||||
the `<workspace_tree>` only lists what exists, so delegate to the specialist
|
||||
to search and read the actual content before answering),
|
||||
- injected workspace context (see `<dynamic_context>`),
|
||||
- results from your other tool calls (`web_search`, `scrape_webpage`),
|
||||
- the user's connected apps via `task(mcp_discovery, ...)` (Slack, Jira,
|
||||
Notion, Gmail, Calendar, etc. — live data that is NOT in the knowledge base),
|
||||
- or substantive summaries returned by a `task` specialist you invoked.
|
||||
|
||||
For questions about the user's own workspace, dispatch
|
||||
For questions about the user's own files and notes, dispatch
|
||||
`task(knowledge_base, ...)` first rather than answering from the tree or from
|
||||
memory. The knowledge_base specialist runs hybrid semantic/keyword search and
|
||||
full-document reads, then returns a grounded summary with `[n]` citation
|
||||
labels for you to carry through into your answer.
|
||||
full-document reads over their personal files and notes, then returns a
|
||||
grounded summary with `[n]` citation labels for you to carry into your answer.
|
||||
For anything living in a connected third-party app, use
|
||||
`task(mcp_discovery, ...)` instead — that content is not indexed in the KB.
|
||||
|
||||
Do **not** answer factual or informational questions from general knowledge
|
||||
unless the user explicitly authorises it after you say you couldn't find
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Structured reasoning:
|
|||
- For non-trivial work, `<thinking>` / short `<plan>` before tool calls is fine.
|
||||
|
||||
Professional objectivity:
|
||||
- Accuracy over flattery; verify with **web_search**, **scrape_webpage**, or **task** when unsure — don’t invent connector access.
|
||||
- Accuracy over flattery; verify with **task** (e.g. `task(web_crawler, …)` to read a page, `task(google_search, …)` for public facts) when unsure — don’t invent connector access.
|
||||
|
||||
Task management:
|
||||
- For 3+ steps, use todo tooling; update statuses promptly.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,6 @@ Output style:
|
|||
Tool calls:
|
||||
- Parallelise independent calls in one turn.
|
||||
- For SurfSense-product questions, point the user to https://www.surfsense.com/docs;
|
||||
use **web_search** / **scrape_webpage** for fresh public facts; integrations and
|
||||
use **task(google_search, …)** / **task(web_crawler, …)** for fresh public facts; integrations and
|
||||
heavy workflows → **task**.
|
||||
</provider_hints>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<reminder>
|
||||
Concise · KB-grounded · delegation-first · one `task` per turn · no direct
|
||||
filesystem · persist memory when durable facts appear.
|
||||
Concise · grounded in this turn's specialist data, never stale general
|
||||
knowledge · delegation-first · no direct filesystem · persist memory when
|
||||
durable facts appear.
|
||||
</reminder>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ You have two execution channels. Pick the one that owns the work — never
|
|||
simulate one with the other.
|
||||
|
||||
### 1. Direct tools (you call them yourself)
|
||||
- `web_search` — search the public web (anything outside the workspace KB).
|
||||
- `scrape_webpage` — fetch the body of a specific public URL.
|
||||
- `update_memory` — curate persistent memory (see `<memory_protocol>`).
|
||||
- `write_todos` — maintain a structured plan when the turn series spans
|
||||
multiple specialists or steps. Mark each item
|
||||
|
|
@ -15,6 +13,63 @@ simulate one with the other.
|
|||
connectors, feature behavior) — point the user to the documentation:
|
||||
https://www.surfsense.com/docs. There is no docs-search tool; give the link.
|
||||
|
||||
**Search discovers — the crawler reads.** Search results (snippets, AI
|
||||
overviews, a specialist's summary of a SERP) are pointers, not sources.
|
||||
When the answer lives on a page — a team roster, a portfolio or directory
|
||||
listing, a pricing table, docs — fetch the page before answering:
|
||||
- One or a few known URLs → `task(web_crawler, …)` with those URLs (it
|
||||
fetches only the seeds at `maxCrawlDepth=0`).
|
||||
- A site section or many pages (a whole team + portfolio, every pricing
|
||||
page of a list of companies, a paginated directory) →
|
||||
`task(web_crawler, …)` with the seed URLs and a higher depth.
|
||||
Never answer with "you can find it at <URL>" for public facts your tools
|
||||
can retrieve — retrieve them, then answer with the facts and cite the page.
|
||||
Large results are fine: extract and return them, don't ask permission for
|
||||
bounded fan-out (≤20 sites) the user already requested.
|
||||
|
||||
**Audience sentiment lives on the platforms.** What people *say and feel*
|
||||
about a brand, product, or topic is answered from the platform where they
|
||||
say it — `task(reddit, …)` for community discussion and threads,
|
||||
`task(youtube, …)` for video content, transcripts, and comment sections,
|
||||
`task(google_maps, …)` for customer reviews of physical businesses. Web
|
||||
search only finds articles *about* the conversation; the platform
|
||||
specialists return the conversation itself, structured and current. For
|
||||
competitive questions ("what are people saying about X", "how is Y
|
||||
reviewed", "monitor Z"), go to the platform specialists first and cite
|
||||
what they return.
|
||||
|
||||
**Places go to Maps, the open web goes to Search.** Discovering physical
|
||||
businesses or venues of a type in a geography ("clinics in X", "tutoring
|
||||
centers near Y", lead lists of local businesses) is the Maps specialist's
|
||||
job — it returns structured name/address/phone/website per place, where
|
||||
web search returns only snippets that need a second pass. Use the Search
|
||||
specialist for entities without a storefront (online-only companies,
|
||||
software vendors, publications), for facts and current events, and to
|
||||
enrich places Maps already found. When a lead list needs both, run Maps
|
||||
discovery first, then crawl (`task(web_crawler, …)`) or search the found
|
||||
websites for contacts.
|
||||
|
||||
**Requested-N lists count distinct entities that fit the ask.** When the
|
||||
user asks for N leads/items/results, every entry must be a distinct
|
||||
*entity* — multiple branches, locations, sub-programs, or pages of the
|
||||
same brand or parent organization are ONE entry, not several. A website
|
||||
domain is an ownership signal: entries whose pages live on the same parent
|
||||
domain (a government portal, a chain's site, a franchise system) share
|
||||
that parent and count as one, judged against the parent. Entries must
|
||||
also fit the user's stated segment: an item that belongs to an excluded
|
||||
category (e.g. a local branch of a large chain when the user asked for
|
||||
independents) does not qualify even if a specialist returned it — drop it,
|
||||
don't relay it. If qualifying results fall short of N, widen the discovery
|
||||
(another specialist call, adjacent geography or segment) to fill the gap
|
||||
honestly; if it still falls short, deliver the smaller list with a
|
||||
one-line note. An honest 10 beats a padded 15.
|
||||
|
||||
**Full datasets become files, not chat.** When the user wants a complete
|
||||
large dataset (an entire roster, portfolio, or directory — or asks for a
|
||||
CSV/file), do not paste or summarize hundreds of rows: instruct the
|
||||
web_crawler specialist to crawl and then save the data with its
|
||||
`export_run` CSV tool, and relay the saved workspace path and row count.
|
||||
|
||||
**You have NO filesystem tools.** Any read, write, edit, move, rename, or
|
||||
search inside the user's workspace goes through `task(knowledge_base, …)` —
|
||||
never via `write_file`, `ls`, or any direct file operation.
|
||||
|
|
@ -64,13 +119,42 @@ user: "Save these meeting notes to my KB: …"
|
|||
|
||||
<example>
|
||||
user: "What did Maya say about the Q2 roadmap in Slack last week?"
|
||||
→ task(slack, "Find messages from Maya about the Q2 roadmap from the past
|
||||
week. Return the most relevant quotes with channel and timestamp.")
|
||||
→ task(mcp_discovery, "In Slack, find messages from Maya about the Q2 roadmap
|
||||
from the past week. Return the most relevant quotes with channel and
|
||||
timestamp.")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "What are people saying about Cursor vs Windsurf lately?"
|
||||
→ Audience sentiment — go to the platform, not web search. Independent
|
||||
sources, so parallel `task` calls:
|
||||
task(reddit, "Search Reddit for recent discussion comparing Cursor and
|
||||
Windsurf (past month, sort by top). Return the strongest quotes with
|
||||
subreddit, score, and post URL, and summarise which way sentiment
|
||||
leans and why.")
|
||||
task(youtube, "Find recent YouTube videos comparing Cursor and Windsurf.
|
||||
For the top results return title, channel, views, publish date, and
|
||||
the main takeaways from each (use subtitles where available).")
|
||||
Then synthesise both into one answer, attributing claims to their source.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "What's the current USD/INR rate?"
|
||||
→ web_search(query="current USD to INR exchange rate")
|
||||
→ Public web lookup — delegate to the Google Search specialist:
|
||||
task(google_search, "Search Google for the current USD to INR exchange
|
||||
rate and return the rate with its source URL.")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Get the a16z team and their portfolio companies."
|
||||
→ Search only *locates* a16z.com/team/ and their investment list — the
|
||||
answer is the CONTENT of those pages. Crawl them and return the extracted
|
||||
people and companies, never just the links:
|
||||
task(web_crawler, "Crawl https://a16z.com/team/ and
|
||||
https://a16z.com/investment-list/ and return (1) the full team roster
|
||||
with each person's name and role/department, and (2) the complete
|
||||
portfolio company list. Use the pages' link records if the markdown
|
||||
is sparse.")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
|
|
@ -82,15 +166,16 @@ user: "Find my Q2 roadmap and summarise the milestones."
|
|||
|
||||
<example>
|
||||
user: "Create a ClickUp ticket and a Linear ticket for the new feature flag."
|
||||
→ Independent work — call both specialists in parallel:
|
||||
→ Independent work, same specialist (connected apps) with non-overlapping
|
||||
scopes — call it twice in parallel, naming the target app in each prompt:
|
||||
write_todos([
|
||||
{content: "Create ClickUp ticket for feature flag rollout", status: "in_progress"},
|
||||
{content: "Create Linear ticket for feature flag rollout", status: "in_progress"},
|
||||
])
|
||||
task(clickup, "Create a ClickUp ticket titled 'Feature flag rollout'
|
||||
in the default list. Description: <…>. Tell me the ticket URL.")
|
||||
task(linear, "Create a Linear ticket titled 'Feature flag rollout'
|
||||
in the default team. Description: <…>. Tell me the ticket URL.")
|
||||
task(mcp_discovery, "In ClickUp, create a ticket titled 'Feature flag
|
||||
rollout' in the default list. Description: <…>. Tell me the ticket URL.")
|
||||
task(mcp_discovery, "In Linear, create a ticket titled 'Feature flag
|
||||
rollout' in the default team. Description: <…>. Tell me the ticket URL.")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
|
|
@ -100,24 +185,25 @@ user: "Find my Q2 roadmap doc in the KB and email a summary to Maya."
|
|||
task(knowledge_base, "Find the Q2 roadmap document under /documents
|
||||
and return its full text plus a 3-bullet summary.")
|
||||
Next turn (with the returned summary in hand):
|
||||
task(gmail, "Send an email to Maya with subject 'Q2 roadmap summary'
|
||||
and the following body: <summary returned by knowledge_base>.")
|
||||
task(mcp_discovery, "In Gmail, send an email to Maya with subject 'Q2
|
||||
roadmap summary' and the following body: <summary returned by
|
||||
knowledge_base>.")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Create issues in Linear for each of these five bugs: <list>"
|
||||
→ Many-shot independent fanout — use the batch shape:
|
||||
task(tasks=[
|
||||
{subagent_type: "linear", description: "Create a Linear issue titled
|
||||
'<bug 1 title>' with body '<bug 1 body>'. Return the issue URL."},
|
||||
{subagent_type: "linear", description: "Create a Linear issue titled
|
||||
'<bug 2 title>' with body '<bug 2 body>'. Return the issue URL."},
|
||||
{subagent_type: "linear", description: "Create a Linear issue titled
|
||||
'<bug 3 title>' with body '<bug 3 body>'. Return the issue URL."},
|
||||
{subagent_type: "linear", description: "Create a Linear issue titled
|
||||
'<bug 4 title>' with body '<bug 4 body>'. Return the issue URL."},
|
||||
{subagent_type: "linear", description: "Create a Linear issue titled
|
||||
'<bug 5 title>' with body '<bug 5 body>'. Return the issue URL."},
|
||||
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
|
||||
titled '<bug 1 title>' with body '<bug 1 body>'. Return the issue URL."},
|
||||
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
|
||||
titled '<bug 2 title>' with body '<bug 2 body>'. Return the issue URL."},
|
||||
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
|
||||
titled '<bug 3 title>' with body '<bug 3 body>'. Return the issue URL."},
|
||||
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
|
||||
titled '<bug 4 title>' with body '<bug 4 body>'. Return the issue URL."},
|
||||
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
|
||||
titled '<bug 5 title>' with body '<bug 5 body>'. Return the issue URL."},
|
||||
])
|
||||
Read back the `[task 0]`…`[task 4]` blocks in the combined ToolMessage and
|
||||
verify each via its Receipt's `verifiable_url` per the `<verification>`
|
||||
|
|
@ -154,16 +240,18 @@ user: "Make a 30-second podcast of this conversation."
|
|||
<example>
|
||||
user: "Post the launch announcement to #general and let me know when it's up."
|
||||
→ Mutating subagent + user wants external confirmation. Apply the
|
||||
`<verification>` teaching: the slack subagent's reply is a self-report;
|
||||
check its `evidence.receipts` for a Receipt with `status="success"` and
|
||||
a `verifiable_url`, then fetch that URL to confirm before reporting back.
|
||||
`<verification>` teaching: the connected-apps subagent's reply is a
|
||||
self-report; check its `evidence.receipts` for a Receipt with
|
||||
`status="success"` and a `verifiable_url`, then fetch that URL to confirm
|
||||
before reporting back.
|
||||
This turn:
|
||||
task(slack, "Post '<launch announcement text>' to #general.
|
||||
Return the message permalink.")
|
||||
task(mcp_discovery, "In Slack, post '<launch announcement text>' to
|
||||
#general. Return the message permalink.")
|
||||
Next turn (with the receipt's `verifiable_url` in hand):
|
||||
scrape_webpage(url=<verifiable_url from slack receipt>)
|
||||
task(web_crawler, "Crawl <verifiable_url from the receipt> and confirm
|
||||
the post is live; return what you find.")
|
||||
→ confirm the post is live, then tell the user it's up with the URL.
|
||||
If the slack reply has NO Receipt with `status="success"`, treat it as a
|
||||
If the reply has NO Receipt with `status="success"`, treat it as a
|
||||
silent failure: surface the error verbatim, do not retry.
|
||||
</example>
|
||||
</routing>
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""``scrape_webpage`` — description + few-shot examples."""
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
- `scrape_webpage` — Fetch and extract readable content from a single URL.
|
||||
- Use when the user wants the actual page body (article, table, dashboard
|
||||
snapshot), not just search snippets.
|
||||
- Try the tool when a URL is given or referenced; don't refuse without
|
||||
attempting unless the URL is clearly unsafe or invalid.
|
||||
- Public web only. For URLs behind a connector (Notion pages, Linear
|
||||
issues, Confluence, anything that needs auth), use `task` with the
|
||||
matching specialist instead.
|
||||
- Args: `url`, `max_length` (default 50000).
|
||||
- Returns title, metadata, and markdown-ish body. Summarise clearly and
|
||||
link back with `[label](url)`.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<example>
|
||||
user: "Check out https://dev.to/some-article"
|
||||
→ scrape_webpage(url="https://dev.to/some-article")
|
||||
(Respond with a structured analysis — key points, takeaways.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
|
||||
→ scrape_webpage(url="https://example.com/blog/ai-trends")
|
||||
(Thorough summary using headings and bullets.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: (after discussing https://example.com/stats) "Can you get the live data from that page?"
|
||||
→ scrape_webpage(url="https://example.com/stats")
|
||||
(Always attempt scraping first. Never refuse before trying.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "https://example.com/blog/weekend-recipes"
|
||||
→ scrape_webpage(url="https://example.com/blog/weekend-recipes")
|
||||
(When a user sends just a URL with no instructions, scrape it and provide
|
||||
a concise summary.)
|
||||
</example>
|
||||
|
|
@ -43,9 +43,9 @@
|
|||
like podcasts/videos), the operation did not happen — treat as
|
||||
failure and surface that to the user verbatim, do not retry blindly.
|
||||
|
||||
2. **`scrape_webpage`** — when a Receipt carries a `verifiable_url`
|
||||
2. **`task(web_crawler, …)`** — when a Receipt carries a `verifiable_url`
|
||||
(Notion page URL, Slack permalink, Jira issue URL, Linear identifier
|
||||
URL, etc.), you can fetch that URL and confirm the operation
|
||||
URL, etc.), you can crawl that URL and confirm the operation
|
||||
externally. Use this for high-stakes mutations the user explicitly
|
||||
called out (e.g. "send the launch email to the whole team") or when
|
||||
the subagent's self-report contradicts what the user expected.
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
|
||||
- `status="success"`: the mutation already committed in the backend.
|
||||
If a `verifiable_url` is present and the request was high-stakes,
|
||||
you may `scrape_webpage` it to externally confirm. Otherwise trust
|
||||
you may crawl it via `task(web_crawler, …)` to externally confirm. Otherwise trust
|
||||
the Receipt and tell the user it is done. Celery-backed deliverables
|
||||
(podcasts, video presentations) also land here — the subagent
|
||||
already waited for the worker to finish, so a `success` Receipt
|
||||
|
|
@ -67,6 +67,6 @@
|
|||
their backend before returning. If you ever do see a pending
|
||||
Receipt, tell the user the work has been **kicked off** (quote the
|
||||
`external_id` / `preview` so they can find it later), do not
|
||||
`scrape_webpage` it, and do not re-dispatch the same
|
||||
crawl it, and do not re-dispatch the same
|
||||
`task(...)` call hoping it will be done "this time".
|
||||
</verification>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ user: "Save these meeting notes to my KB: …"
|
|||
|
||||
<example>
|
||||
user: "What did Maya say about the Q2 roadmap in Slack last week?"
|
||||
→ task(subagent_type="slack", description="Find messages from Maya about
|
||||
the Q2 roadmap from the past week. Return the most relevant quotes with
|
||||
channel and timestamp.")
|
||||
→ task(subagent_type="mcp_discovery", description="In Slack, find messages
|
||||
from Maya about the Q2 roadmap from the past week. Return the most relevant
|
||||
quotes with channel and timestamp.")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
- `update_memory` — Curate the team's **shared** long-term memory document
|
||||
for this search space.
|
||||
for this workspace.
|
||||
- The current memory (if any) appears in `<team_memory>` with usage vs limit.
|
||||
- Call when a team member asks to remember or forget something, or when
|
||||
the conversation surfaces durable team decisions, conventions,
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""``web_search`` — description + few-shot examples."""
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
- `web_search` — Search the public web.
|
||||
- Use whenever an answer benefits from external sources — current events,
|
||||
prices, weather, news, technical references, definitions, background
|
||||
facts, anything outside SurfSense docs and the workspace KB. Reach for
|
||||
it whenever freshness matters or you'd otherwise guess from memory.
|
||||
- Don't refuse with "I lack network access" — call the tool.
|
||||
- Returns a `<web_results>` block: each result is labelled `[n]`. Cite a
|
||||
result by writing that `[n]` after the statement it supports (when
|
||||
citations are enabled) — do not hand-write the URL as a markdown link.
|
||||
- If results are thin, say so and offer to refine the query.
|
||||
- Args: `query`, `top_k` (default 10, max 50).
|
||||
- Follow up with `scrape_webpage` on the best URL when snippets are too
|
||||
shallow.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<example>
|
||||
user: "What's the current USD to INR exchange rate?"
|
||||
→ web_search(query="current USD to INR exchange rate")
|
||||
(Answer from snippets; scrape a top URL if needed.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "What's the latest news about AI?"
|
||||
→ web_search(query="latest AI news today")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "What's the weather in New York?"
|
||||
→ web_search(query="weather New York today")
|
||||
</example>
|
||||
|
|
@ -45,14 +45,14 @@ _JSON_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
|
|||
|
||||
def create_create_automation_tool(
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | UUID,
|
||||
llm: Any,
|
||||
auth_context: AuthContext | None = None,
|
||||
):
|
||||
"""Factory for the ``create_automation`` tool.
|
||||
|
||||
``search_space_id`` is injected from the chat session (the model never
|
||||
``workspace_id`` is injected from the chat session (the model never
|
||||
has to guess it). ``llm`` is the drafting sub-model — we reuse the main
|
||||
agent's LLM and tag the call so it's identifiable in traces. A fresh
|
||||
``AsyncSession`` is opened per call to avoid stale sessions on
|
||||
|
|
@ -101,12 +101,12 @@ def create_create_automation_tool(
|
|||
"""
|
||||
# Models are chosen per-automation on the approval card (premium/BYOK
|
||||
# selectors) and validated when persisted by ``AutomationService.create``
|
||||
# — so there's no fail-fast search-space eligibility gate here. The
|
||||
# search space's current chat/role model selection no longer constrains
|
||||
# — so there's no fail-fast workspace eligibility gate here. The
|
||||
# workspace's current chat/role model selection no longer constrains
|
||||
# whether an automation can be drafted or saved.
|
||||
|
||||
# --- 1. Draft via sub-LLM ---
|
||||
prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent)
|
||||
prompt = build_draft_prompt(workspace_id=workspace_id, intent=intent)
|
||||
try:
|
||||
response = await llm.ainvoke(
|
||||
[HumanMessage(content=prompt)],
|
||||
|
|
@ -125,8 +125,8 @@ def create_create_automation_tool(
|
|||
"raw": raw_text,
|
||||
}
|
||||
|
||||
# search_space_id is injected here so the sub-LLM never has to guess.
|
||||
draft["search_space_id"] = search_space_id
|
||||
# workspace_id is injected here so the sub-LLM never has to guess.
|
||||
draft["workspace_id"] = workspace_id
|
||||
try:
|
||||
validated_draft = AutomationCreate.model_validate(draft)
|
||||
except ValidationError as exc:
|
||||
|
|
@ -139,14 +139,14 @@ def create_create_automation_tool(
|
|||
# --- 2. HITL approval card ---
|
||||
try:
|
||||
card_params = validated_draft.model_dump(mode="json", by_alias=True)
|
||||
# search_space_id is session-scoped, not user-editable.
|
||||
card_params.pop("search_space_id", None)
|
||||
# workspace_id is session-scoped, not user-editable.
|
||||
card_params.pop("workspace_id", None)
|
||||
|
||||
result = request_approval(
|
||||
action_type="automation_create",
|
||||
tool_name="create_automation",
|
||||
params=card_params,
|
||||
context={"search_space_id": search_space_id},
|
||||
context={"workspace_id": workspace_id},
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ def create_create_automation_tool(
|
|||
}
|
||||
|
||||
# --- 3. Persist (re-validate in case the user edited) ---
|
||||
final_payload = {**result.params, "search_space_id": search_space_id}
|
||||
final_payload = {**result.params, "workspace_id": workspace_id}
|
||||
try:
|
||||
final_validated = AutomationCreate.model_validate(final_payload)
|
||||
except ValidationError as exc:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ into a SINGLE JSON object matching the AutomationCreate schema. Output
|
|||
ONLY that JSON object — no prose, no markdown fence, no commentary.
|
||||
|
||||
Current UTC time (for cron context): {now}
|
||||
Target search_space_id: {search_space_id}
|
||||
Target workspace_id: {workspace_id}
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -165,12 +165,12 @@ User intent:
|
|||
"""
|
||||
|
||||
|
||||
def build_draft_prompt(*, search_space_id: int, intent: str) -> str:
|
||||
def build_draft_prompt(*, workspace_id: int, intent: str) -> str:
|
||||
"""Render the drafting sub-LLM system prompt for the given intent."""
|
||||
return (
|
||||
_HEADER.format(
|
||||
now=datetime.now(UTC).isoformat(timespec="seconds"),
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
+ _SCHEMA
|
||||
+ _FEW_SHOTS
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ Connector integrations, MCP, deliverables, etc. are delegated via ``task`` subag
|
|||
from __future__ import annotations
|
||||
|
||||
MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: tuple[str, ...] = (
|
||||
"web_search",
|
||||
"scrape_webpage",
|
||||
"update_memory",
|
||||
"create_automation",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,27 +21,14 @@ from typing import Any
|
|||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.chat.shared.tools.web_search import create_web_search_tool
|
||||
from app.db import ChatVisibility
|
||||
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
from .update_memory import (
|
||||
create_update_memory_tool,
|
||||
create_update_team_memory_tool,
|
||||
)
|
||||
|
||||
|
||||
def _build_scrape_webpage_tool(deps: dict[str, Any]) -> BaseTool:
|
||||
return create_scrape_webpage_tool(firecrawl_api_key=deps.get("firecrawl_api_key"))
|
||||
|
||||
|
||||
def _build_web_search_tool(deps: dict[str, Any]) -> BaseTool:
|
||||
return create_web_search_tool(
|
||||
search_space_id=deps.get("search_space_id"),
|
||||
available_connectors=deps.get("available_connectors"),
|
||||
)
|
||||
|
||||
|
||||
def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
|
||||
# Deferred import: the automation package is a sibling under ``main_agent``
|
||||
# and is only needed at build time, mirroring the shared registry's
|
||||
|
|
@ -49,7 +36,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
|
|||
from .automation import create_create_automation_tool
|
||||
|
||||
return create_create_automation_tool(
|
||||
search_space_id=deps["search_space_id"],
|
||||
workspace_id=deps["workspace_id"],
|
||||
user_id=deps["user_id"],
|
||||
auth_context=deps.get("auth_context"),
|
||||
llm=deps["llm"],
|
||||
|
|
@ -59,7 +46,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
|
|||
def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool:
|
||||
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE:
|
||||
return create_update_team_memory_tool(
|
||||
search_space_id=deps["search_space_id"],
|
||||
workspace_id=deps["workspace_id"],
|
||||
db_session=deps["db_session"],
|
||||
llm=deps.get("llm"),
|
||||
)
|
||||
|
|
@ -71,20 +58,18 @@ def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool:
|
|||
|
||||
|
||||
# Ordered to match the historical main-agent binding order:
|
||||
# scrape_webpage, web_search, create_automation, update_memory.
|
||||
# create_automation, update_memory.
|
||||
# Each entry is ``(factory, required_dependency_names)``.
|
||||
_MAIN_AGENT_TOOL_FACTORIES: dict[
|
||||
str, tuple[Callable[[dict[str, Any]], BaseTool], tuple[str, ...]]
|
||||
] = {
|
||||
"scrape_webpage": (_build_scrape_webpage_tool, ()),
|
||||
"web_search": (_build_web_search_tool, ()),
|
||||
"create_automation": (
|
||||
_build_create_automation_tool,
|
||||
("search_space_id", "user_id", "llm"),
|
||||
("workspace_id", "user_id", "llm"),
|
||||
),
|
||||
"update_memory": (
|
||||
_build_update_memory_tool,
|
||||
("user_id", "search_space_id", "db_session", "thread_visibility", "llm"),
|
||||
("user_id", "workspace_id", "db_session", "thread_visibility", "llm"),
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,303 +0,0 @@
|
|||
"""
|
||||
Web scraping tool for the SurfSense agent.
|
||||
|
||||
This module provides a tool for scraping and extracting content from webpages
|
||||
using the existing WebCrawlerConnector. For YouTube URLs, it fetches the
|
||||
transcript directly via the YouTubeTranscriptApi instead of crawling the page.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fake_useragent import UserAgent
|
||||
from langchain_core.tools import tool
|
||||
from requests import Session
|
||||
from scrapling.fetchers import AsyncFetcher
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
|
||||
from app.connectors.webcrawler_connector import WebCrawlerConnector
|
||||
from app.tasks.document_processors.youtube_processor import get_youtube_video_id
|
||||
from app.utils.proxy import get_proxy_url, get_requests_proxies
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_domain(url: str) -> str:
|
||||
"""Extract the domain from a URL."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc
|
||||
if domain.startswith("www."):
|
||||
domain = domain[4:]
|
||||
return domain
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def generate_scrape_id(url: str) -> str:
|
||||
"""Generate a unique ID for a scraped webpage."""
|
||||
hash_val = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
return f"scrape-{hash_val}"
|
||||
|
||||
|
||||
def truncate_content(content: str, max_length: int = 50000) -> tuple[str, bool]:
|
||||
"""
|
||||
Truncate content to a maximum length.
|
||||
|
||||
Returns:
|
||||
Tuple of (truncated_content, was_truncated)
|
||||
"""
|
||||
if len(content) <= max_length:
|
||||
return content, False
|
||||
|
||||
# Prefer truncating at a sentence/paragraph boundary.
|
||||
truncated = content[:max_length]
|
||||
last_period = truncated.rfind(".")
|
||||
last_newline = truncated.rfind("\n\n")
|
||||
|
||||
boundary = max(last_period, last_newline)
|
||||
if boundary > max_length * 0.8: # only if the boundary isn't too far back
|
||||
truncated = content[: boundary + 1]
|
||||
|
||||
return truncated + "\n\n[Content truncated...]", True
|
||||
|
||||
|
||||
async def _scrape_youtube_video(
|
||||
url: str, video_id: str, max_length: int
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Fetch YouTube video metadata and transcript via the YouTubeTranscriptApi.
|
||||
|
||||
Returns a result dict in the same shape as the regular scrape_webpage output.
|
||||
"""
|
||||
scrape_id = generate_scrape_id(url)
|
||||
domain = "youtube.com"
|
||||
|
||||
# --- Video metadata via oEmbed ---
|
||||
residential_proxies = get_requests_proxies()
|
||||
|
||||
params = {
|
||||
"format": "json",
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
}
|
||||
oembed_url = "https://www.youtube.com/oembed"
|
||||
|
||||
try:
|
||||
oembed_fetch_start = time.perf_counter()
|
||||
oembed_page = await AsyncFetcher.get(
|
||||
oembed_url,
|
||||
params=params,
|
||||
proxy=get_proxy_url(),
|
||||
stealthy_headers=True,
|
||||
)
|
||||
logger.info(
|
||||
"[scrape_webpage][perf] source=oembed video=%s status=%s fetch_ms=%.1f",
|
||||
video_id,
|
||||
getattr(oembed_page, "status", None),
|
||||
(time.perf_counter() - oembed_fetch_start) * 1000,
|
||||
)
|
||||
video_data = oembed_page.json()
|
||||
except Exception:
|
||||
video_data = {}
|
||||
|
||||
title = video_data.get("title", "YouTube Video")
|
||||
author = video_data.get("author_name", "Unknown")
|
||||
|
||||
# --- Transcript via YouTubeTranscriptApi ---
|
||||
try:
|
||||
transcript_fetch_start = time.perf_counter()
|
||||
ua = UserAgent()
|
||||
http_client = Session()
|
||||
http_client.headers.update({"User-Agent": ua.random})
|
||||
if residential_proxies:
|
||||
http_client.proxies.update(residential_proxies)
|
||||
ytt_api = YouTubeTranscriptApi(http_client=http_client)
|
||||
|
||||
# Pick the first transcript (video's primary language) rather than
|
||||
# defaulting to English.
|
||||
transcript_list = ytt_api.list(video_id)
|
||||
transcript = next(iter(transcript_list))
|
||||
captions = transcript.fetch()
|
||||
|
||||
logger.info(
|
||||
"[scrape_webpage][perf] source=transcript video=%s fetch_ms=%.1f",
|
||||
video_id,
|
||||
(time.perf_counter() - transcript_fetch_start) * 1000,
|
||||
)
|
||||
logger.info(
|
||||
f"[scrape_webpage] Fetched transcript for {video_id} "
|
||||
f"in {transcript.language} ({transcript.language_code})"
|
||||
)
|
||||
|
||||
transcript_segments = []
|
||||
for line in captions:
|
||||
start_time = line.start
|
||||
duration = line.duration
|
||||
text = line.text
|
||||
timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]"
|
||||
transcript_segments.append(f"{timestamp} {text}")
|
||||
transcript_text = "\n".join(transcript_segments)
|
||||
except Exception as e:
|
||||
logger.warning(f"[scrape_webpage] No transcript for video {video_id}: {e}")
|
||||
transcript_text = f"No captions available for this video. Error: {e!s}"
|
||||
|
||||
content = f"# {title}\n\n**Author:** {author}\n**Video ID:** {video_id}\n\n## Transcript\n\n{transcript_text}"
|
||||
|
||||
content, was_truncated = truncate_content(content, max_length)
|
||||
word_count = len(content.split())
|
||||
|
||||
description = f"YouTube video by {author}"
|
||||
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"content": content,
|
||||
"domain": domain,
|
||||
"word_count": word_count,
|
||||
"was_truncated": was_truncated,
|
||||
"crawler_type": "youtube_transcript",
|
||||
"author": author,
|
||||
}
|
||||
|
||||
|
||||
def create_scrape_webpage_tool(firecrawl_api_key: str | None = None):
|
||||
"""
|
||||
Factory function to create the scrape_webpage tool.
|
||||
|
||||
Args:
|
||||
firecrawl_api_key: Optional Firecrawl API key for premium web scraping.
|
||||
Falls back to Chromium/Trafilatura if not provided.
|
||||
|
||||
Returns:
|
||||
A configured tool function for scraping webpages.
|
||||
"""
|
||||
|
||||
@tool
|
||||
async def scrape_webpage(
|
||||
url: str,
|
||||
max_length: int = 50000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Scrape and extract the main content from a webpage.
|
||||
|
||||
Use this tool when the user wants you to read, summarize, or answer
|
||||
questions about a specific webpage's content. This tool actually
|
||||
fetches and reads the full page content. For YouTube video URLs it
|
||||
fetches the transcript directly instead of crawling the page.
|
||||
|
||||
Common triggers:
|
||||
- "Read this article and summarize it"
|
||||
- "What does this page say about X?"
|
||||
- "Summarize this blog post for me"
|
||||
- "Tell me the key points from this article"
|
||||
- "What's in this webpage?"
|
||||
|
||||
Args:
|
||||
url: The URL of the webpage to scrape (must be HTTP/HTTPS)
|
||||
max_length: Maximum content length to return (default: 50000 chars)
|
||||
|
||||
Returns:
|
||||
A dictionary containing:
|
||||
- id: Unique identifier for this scrape
|
||||
- assetId: The URL (for deduplication)
|
||||
- kind: "article" (type of content)
|
||||
- href: The URL to open when clicked
|
||||
- title: Page title
|
||||
- description: Brief description or excerpt
|
||||
- content: The extracted main content (markdown format)
|
||||
- domain: The domain name
|
||||
- word_count: Approximate word count
|
||||
- was_truncated: Whether content was truncated
|
||||
- error: Error message (if scraping failed)
|
||||
"""
|
||||
scrape_id = generate_scrape_id(url)
|
||||
domain = extract_domain(url)
|
||||
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = f"https://{url}"
|
||||
|
||||
try:
|
||||
# YouTube URLs use the transcript API instead of crawling.
|
||||
video_id = get_youtube_video_id(url)
|
||||
if video_id:
|
||||
return await _scrape_youtube_video(url, video_id, max_length)
|
||||
|
||||
connector = WebCrawlerConnector(firecrawl_api_key=firecrawl_api_key)
|
||||
result, error = await connector.crawl_url(url, formats=["markdown"])
|
||||
|
||||
if error:
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": domain or "Webpage",
|
||||
"domain": domain,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
if not result:
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": domain or "Webpage",
|
||||
"domain": domain,
|
||||
"error": "No content returned from crawler",
|
||||
}
|
||||
|
||||
content = result.get("content", "")
|
||||
metadata = result.get("metadata", {})
|
||||
|
||||
title = metadata.get("title", "")
|
||||
if not title:
|
||||
title = domain or url.split("/")[-1] or "Webpage"
|
||||
|
||||
description = metadata.get("description", "")
|
||||
if not description and content:
|
||||
first_para = content.split("\n\n")[0] if content else ""
|
||||
description = (
|
||||
first_para[:300] + "..." if len(first_para) > 300 else first_para
|
||||
)
|
||||
|
||||
content, was_truncated = truncate_content(content, max_length)
|
||||
word_count = len(content.split())
|
||||
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"content": content,
|
||||
"domain": domain,
|
||||
"word_count": word_count,
|
||||
"was_truncated": was_truncated,
|
||||
"crawler_type": result.get("crawler_type", "unknown"),
|
||||
"author": metadata.get("author"),
|
||||
"date": metadata.get("date"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(f"[scrape_webpage] Error scraping {url}: {error_message}")
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": domain or "Webpage",
|
||||
"domain": domain,
|
||||
"error": f"Failed to scrape: {error_message[:100]}",
|
||||
}
|
||||
|
||||
return scrape_webpage
|
||||
|
|
@ -53,7 +53,7 @@ def create_update_memory_tool(
|
|||
|
||||
|
||||
def create_update_team_memory_tool(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
db_session: AsyncSession,
|
||||
llm: Any | None = None,
|
||||
):
|
||||
|
|
@ -62,7 +62,7 @@ def create_update_team_memory_tool(
|
|||
|
||||
@tool
|
||||
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
||||
"""Update the team's shared memory document for this search space.
|
||||
"""Update the team's shared memory document for this workspace.
|
||||
|
||||
The current team memory is shown in <team_memory>. Pass the FULL updated
|
||||
markdown document, not a diff.
|
||||
|
|
@ -71,7 +71,7 @@ def create_update_team_memory_tool(
|
|||
async with async_session_maker() as db_session:
|
||||
result = await save_memory(
|
||||
scope=MemoryScope.TEAM,
|
||||
target_id=search_space_id,
|
||||
target_id=workspace_id,
|
||||
content=updated_memory,
|
||||
session=db_session,
|
||||
llm=llm,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"""Render citable documents for the model: one shape for search, read, and web.
|
||||
"""Render citable documents for the model: one shape for search and read.
|
||||
|
||||
``render_document`` emits one ``<document title=… source=… view="excerpt|full">``
|
||||
block whose passages carry server-assigned ``[n]`` labels. ``render_search_context``
|
||||
wraps KB excerpt blocks in ``<retrieved_context>``; ``render_web_results`` wraps web
|
||||
excerpt blocks in ``<web_results>``. Both cite with the same ``[n]`` spine.
|
||||
wraps KB excerpt blocks in ``<retrieved_context>`` and cites with the ``[n]`` spine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -12,7 +11,6 @@ from .document import render_document
|
|||
from .models import DocumentView, RenderableDocument, RenderablePassage
|
||||
from .search_context import render_search_context
|
||||
from .source_label import source_label
|
||||
from .web_results import render_web_results
|
||||
|
||||
__all__ = [
|
||||
"DocumentView",
|
||||
|
|
@ -20,6 +18,5 @@ __all__ = [
|
|||
"RenderablePassage",
|
||||
"render_document",
|
||||
"render_search_context",
|
||||
"render_web_results",
|
||||
"source_label",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
"""Wrap live web-search results in a ``<web_results>`` block.
|
||||
|
||||
Each result renders through the shared ``render_document`` (excerpt view), so a
|
||||
web result is cited with ``[n]`` exactly like a knowledge-base passage. Only the
|
||||
container and header differ — they tell the model these came from the public web,
|
||||
not the user's workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.citations import CitationRegistry
|
||||
|
||||
from .document import render_document
|
||||
from .models import RenderableDocument
|
||||
|
||||
_HEADER = (
|
||||
"These are live results from a public web search for this query. Each\n"
|
||||
"<document> below is one result in excerpt view; cite a result with its [n]\n"
|
||||
"after the statement it supports. Scrape the URL for full context before\n"
|
||||
"making a definitive claim from a snippet."
|
||||
)
|
||||
|
||||
|
||||
def render_web_results(
|
||||
documents: list[RenderableDocument],
|
||||
registry: CitationRegistry,
|
||||
) -> str | None:
|
||||
"""Render web results as excerpt blocks inside ``<web_results>``.
|
||||
|
||||
Returns ``None`` when no result has content to show, so the caller can skip
|
||||
the block. Mutates ``registry`` (find-or-create), so a URL seen again keeps
|
||||
its original ``[n]``.
|
||||
"""
|
||||
blocks = [
|
||||
block
|
||||
for document in documents
|
||||
if (block := render_document(document, view="excerpt", registry=registry))
|
||||
is not None
|
||||
]
|
||||
if not blocks:
|
||||
return None
|
||||
|
||||
return "<web_results>\n" + _HEADER + "\n" + "\n".join(blocks) + "\n</web_results>"
|
||||
|
||||
|
||||
__all__ = ["render_web_results"]
|
||||
|
|
@ -120,8 +120,8 @@ class KBPostgresBackend(BackendProtocol):
|
|||
|
||||
_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
|
||||
|
||||
def __init__(self, search_space_id: int, runtime: ToolRuntime) -> None:
|
||||
self.search_space_id = search_space_id
|
||||
def __init__(self, workspace_id: int, runtime: ToolRuntime) -> None:
|
||||
self.workspace_id = workspace_id
|
||||
self.runtime = runtime
|
||||
|
||||
@property
|
||||
|
|
@ -405,7 +405,7 @@ class KBPostgresBackend(BackendProtocol):
|
|||
if not normalized_path.startswith(DOCUMENTS_ROOT):
|
||||
return [], set()
|
||||
|
||||
index = await build_path_index(session, self.search_space_id)
|
||||
index = await build_path_index(session, self.workspace_id)
|
||||
target_folder_id: int | None = None
|
||||
if normalized_path != DOCUMENTS_ROOT:
|
||||
target_path = normalized_path
|
||||
|
|
@ -418,7 +418,7 @@ class KBPostgresBackend(BackendProtocol):
|
|||
|
||||
result = await session.execute(
|
||||
select(Document.id, Document.title, Document.folder_id, Document.updated_at)
|
||||
.where(Document.search_space_id == self.search_space_id)
|
||||
.where(Document.workspace_id == self.workspace_id)
|
||||
.where(
|
||||
Document.folder_id == target_folder_id
|
||||
if target_folder_id is not None
|
||||
|
|
@ -519,7 +519,7 @@ class KBPostgresBackend(BackendProtocol):
|
|||
async with shielded_async_session() as session:
|
||||
document_row = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=self.search_space_id,
|
||||
workspace_id=self.workspace_id,
|
||||
virtual_path=path,
|
||||
)
|
||||
if document_row is None:
|
||||
|
|
@ -667,10 +667,10 @@ class KBPostgresBackend(BackendProtocol):
|
|||
if normalized.startswith(DOCUMENTS_ROOT) or normalized == "/":
|
||||
try:
|
||||
async with shielded_async_session() as session:
|
||||
index = await build_path_index(session, self.search_space_id)
|
||||
index = await build_path_index(session, self.workspace_id)
|
||||
rows = await session.execute(
|
||||
select(Document.id, Document.title, Document.folder_id).where(
|
||||
Document.search_space_id == self.search_space_id
|
||||
Document.workspace_id == self.workspace_id
|
||||
)
|
||||
)
|
||||
for row in rows.all():
|
||||
|
|
@ -747,11 +747,11 @@ class KBPostgresBackend(BackendProtocol):
|
|||
if normalized.startswith(DOCUMENTS_ROOT) or normalized == "/":
|
||||
try:
|
||||
async with shielded_async_session() as session:
|
||||
index = await build_path_index(session, self.search_space_id)
|
||||
index = await build_path_index(session, self.workspace_id)
|
||||
sub = (
|
||||
select(Chunk.document_id, Chunk.id, Chunk.content)
|
||||
.join(Document, Document.id == Chunk.document_id)
|
||||
.where(Document.search_space_id == self.search_space_id)
|
||||
.where(Document.workspace_id == self.workspace_id)
|
||||
.where(Chunk.content.ilike(f"%{pattern}%"))
|
||||
.order_by(Chunk.document_id, Chunk.position, Chunk.id)
|
||||
)
|
||||
|
|
@ -849,14 +849,14 @@ class KBPostgresBackend(BackendProtocol):
|
|||
|
||||
try:
|
||||
async with shielded_async_session() as session:
|
||||
index = await build_path_index(session, self.search_space_id)
|
||||
index = await build_path_index(session, self.workspace_id)
|
||||
doc_rows_raw = await session.execute(
|
||||
select(
|
||||
Document.id,
|
||||
Document.title,
|
||||
Document.folder_id,
|
||||
Document.updated_at,
|
||||
).where(Document.search_space_id == self.search_space_id)
|
||||
).where(Document.workspace_id == self.workspace_id)
|
||||
)
|
||||
doc_rows = list(doc_rows_raw.all())
|
||||
except Exception as exc: # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ def _cached_multi_root_backend(
|
|||
def build_backend_resolver(
|
||||
selection: FilesystemSelection,
|
||||
*,
|
||||
search_space_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
) -> Callable[[ToolRuntime], BackendProtocol]:
|
||||
"""Create deepagents backend resolver for the selected filesystem mode.
|
||||
|
||||
In cloud mode the resolver returns a fresh :class:`KBPostgresBackend`
|
||||
bound to the current ``runtime`` so the backend can read staging state
|
||||
(``staged_dirs``, ``pending_moves``, ``files`` cache, ``kb_anon_doc``)
|
||||
for each tool call. When no ``search_space_id``
|
||||
for each tool call. When no ``workspace_id``
|
||||
is provided, the resolver falls back to :class:`StateBackend` (used by
|
||||
sub-agents and tests that don't need DB-backed reads).
|
||||
|
||||
|
|
@ -55,10 +55,10 @@ def build_backend_resolver(
|
|||
|
||||
return _resolve_local
|
||||
|
||||
if search_space_id is not None:
|
||||
if workspace_id is not None:
|
||||
|
||||
def _resolve_kb(runtime: ToolRuntime) -> BackendProtocol:
|
||||
return KBPostgresBackend(search_space_id, runtime)
|
||||
return KBPostgresBackend(workspace_id, runtime)
|
||||
|
||||
return _resolve_kb
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ def build_filesystem_mw(
|
|||
*,
|
||||
backend_resolver: Any,
|
||||
filesystem_mode: FilesystemMode,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str | None,
|
||||
thread_id: int | None,
|
||||
read_only: bool = False,
|
||||
|
|
@ -21,7 +21,7 @@ def build_filesystem_mw(
|
|||
return SurfSenseFilesystemMiddleware(
|
||||
backend=backend_resolver,
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=user_id,
|
||||
thread_id=thread_id,
|
||||
read_only=read_only,
|
||||
|
|
|
|||
|
|
@ -49,14 +49,14 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
|
|||
*,
|
||||
backend: Any = None,
|
||||
filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
|
||||
search_space_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
created_by_id: str | None = None,
|
||||
thread_id: int | str | None = None,
|
||||
tool_token_limit_before_evict: int | None = 20000,
|
||||
read_only: bool = False,
|
||||
) -> None:
|
||||
self._filesystem_mode = filesystem_mode
|
||||
self._search_space_id = search_space_id
|
||||
self._workspace_id = workspace_id
|
||||
self._created_by_id = created_by_id
|
||||
self._thread_id = thread_id
|
||||
self._read_only = read_only
|
||||
|
|
|
|||
|
|
@ -33,10 +33,13 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
# Subagent that emitted this receipt.
|
||||
# Subagent that emitted this receipt. ``mcp_discovery`` is the current
|
||||
# connected-apps route; the per-connector literals below it are retained so
|
||||
# historical receipts (persisted in old checkpoints) still type-check.
|
||||
ReceiptRoute = Literal[
|
||||
"deliverables",
|
||||
"knowledge_base",
|
||||
"mcp_discovery",
|
||||
"notion",
|
||||
"slack",
|
||||
"gmail",
|
||||
|
|
@ -102,7 +105,7 @@ class Receipt(TypedDict, total=False):
|
|||
``None`` only when the operation failed before the backend assigned one."""
|
||||
|
||||
verifiable_url: str | None
|
||||
"""URL the parent can pass to ``scrape_webpage`` to verify the
|
||||
"""URL the parent can crawl (via ``task(web_crawler, …)``) to verify the
|
||||
operation. ``None`` when no public URL exists (Gmail, KB, raw images
|
||||
stored in the DB)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
"""Knowledge-base retrieval: hybrid search rendered as citable evidence.
|
||||
|
||||
Public surface is the service (``search_knowledge_base_context``) and its input
|
||||
value object (``SearchScope``); the rest are building blocks.
|
||||
Public surface is ``build_context`` (rerank → adapt → render) and the
|
||||
``SearchScope`` input value object; the rest are building blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import ChunkHit, DocumentHit, SearchScope
|
||||
from .service import build_context, search_knowledge_base_context
|
||||
from .service import build_context
|
||||
|
||||
__all__ = [
|
||||
"ChunkHit",
|
||||
"DocumentHit",
|
||||
"SearchScope",
|
||||
"build_context",
|
||||
"search_knowledge_base_context",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ _SURFACE = "chunks"
|
|||
async def search_chunks(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
query: str,
|
||||
scope: SearchScope,
|
||||
top_k: int,
|
||||
|
|
@ -44,14 +44,14 @@ async def search_chunks(
|
|||
"""
|
||||
started = time.perf_counter()
|
||||
with otel.kb_search_span(
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
query_chars=len(query),
|
||||
extra={"search.surface": _SURFACE, "search.mode": "hybrid"},
|
||||
) as span:
|
||||
try:
|
||||
documents = await _search(
|
||||
db_session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
query=query,
|
||||
scope=scope,
|
||||
top_k=top_k,
|
||||
|
|
@ -60,14 +60,14 @@ async def search_chunks(
|
|||
finally:
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000
|
||||
metrics.record_kb_search_duration(
|
||||
elapsed_ms, search_space_id=search_space_id, surface=_SURFACE
|
||||
elapsed_ms, workspace_id=workspace_id, surface=_SURFACE
|
||||
)
|
||||
span.set_attribute("result.count", len(documents))
|
||||
get_perf_logger().info(
|
||||
"[chunk_search] hybrid in %.3fs docs=%d space=%d",
|
||||
elapsed_ms / 1000,
|
||||
len(documents),
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
)
|
||||
return documents
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ async def search_chunks(
|
|||
async def _search(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
query: str,
|
||||
scope: SearchScope,
|
||||
top_k: int,
|
||||
|
|
@ -91,7 +91,7 @@ async def _search(
|
|||
config.embedding_model_instance.embed, query
|
||||
)
|
||||
|
||||
conditions = _base_conditions(search_space_id, scope, document_types)
|
||||
conditions = _base_conditions(workspace_id, scope, document_types)
|
||||
rows = await _fused_chunks(
|
||||
db_session,
|
||||
query=query,
|
||||
|
|
@ -116,13 +116,13 @@ def _resolve_document_types(
|
|||
|
||||
|
||||
def _base_conditions(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
scope: SearchScope,
|
||||
document_types: list[DocumentType] | None,
|
||||
) -> list:
|
||||
"""Filters shared by both search legs."""
|
||||
conditions = [
|
||||
Document.search_space_id == search_space_id,
|
||||
Document.workspace_id == workspace_id,
|
||||
func.coalesce(Document.status["state"].astext, "ready") != "deleting",
|
||||
]
|
||||
if document_types:
|
||||
|
|
|
|||
|
|
@ -1,54 +1,27 @@
|
|||
"""Search the knowledge base and render it as model-facing ``<retrieved_context>``.
|
||||
"""Render knowledge-base hits as model-facing ``<retrieved_context>``.
|
||||
|
||||
The retrieval spine end to end: hybrid search → rerank → adapt → render, with
|
||||
each shown passage registered for ``[n]`` citation along the way.
|
||||
The tail of the retrieval spine: rerank → adapt → render, registering each
|
||||
shown passage for ``[n]`` citation. Hybrid search itself lives in
|
||||
``hybrid_search``; callers (the ``search_knowledge_base`` tool) pass its hits
|
||||
straight into :func:`build_context`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.citations import CitationRegistry
|
||||
from app.agents.chat.multi_agent_chat.shared.document_render import (
|
||||
render_search_context,
|
||||
)
|
||||
|
||||
from .adapter import to_renderable_document
|
||||
from .hybrid_search import search_chunks
|
||||
from .models import DocumentHit, SearchScope
|
||||
from .models import DocumentHit
|
||||
from .reranking import rerank_hits
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.reranker_service import RerankerService
|
||||
|
||||
_DEFAULT_TOP_K = 10
|
||||
|
||||
|
||||
async def search_knowledge_base_context(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
query: str,
|
||||
registry: CitationRegistry,
|
||||
scope: SearchScope | None = None,
|
||||
reranker: RerankerService | None = None,
|
||||
top_k: int = _DEFAULT_TOP_K,
|
||||
) -> str | None:
|
||||
"""Retrieve KB evidence for ``query`` and render it, registering each ``[n]``.
|
||||
|
||||
Returns ``None`` when nothing matched, so the caller can skip the block.
|
||||
"""
|
||||
hits = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=search_space_id,
|
||||
query=query,
|
||||
scope=scope or SearchScope(),
|
||||
top_k=top_k,
|
||||
)
|
||||
return build_context(query, hits, registry, reranker=reranker)
|
||||
|
||||
|
||||
def build_context(
|
||||
query: str,
|
||||
|
|
@ -63,4 +36,4 @@ def build_context(
|
|||
return render_search_context(documents, registry)
|
||||
|
||||
|
||||
__all__ = ["build_context", "search_knowledge_base_context"]
|
||||
__all__ = ["build_context"]
|
||||
|
|
|
|||
|
|
@ -64,14 +64,6 @@ TOOL_CATALOG: list[ToolMetadata] = [
|
|||
name="search_knowledge_base",
|
||||
description="Search the user's knowledge base with hybrid semantic + keyword retrieval",
|
||||
),
|
||||
ToolMetadata(
|
||||
name="scrape_webpage",
|
||||
description="Scrape and extract the main content from a webpage",
|
||||
),
|
||||
ToolMetadata(
|
||||
name="web_search",
|
||||
description="Search the web for real-time information using configured search engines",
|
||||
),
|
||||
ToolMetadata(
|
||||
name="create_automation",
|
||||
description="Draft an automation from an NL intent; user approves the card; tool saves",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
|||
# artifact in the user's own workspace with no external visibility (drafts
|
||||
# aren't sent; new files aren't shared). They still call ``request_approval``,
|
||||
# which returns ``decision_type="auto_approved"`` without firing an interrupt.
|
||||
# Per-search-space ``agent_permission_rules`` can re-enable prompting.
|
||||
# Per-workspace ``agent_permission_rules`` can re-enable prompting.
|
||||
DEFAULT_AUTO_APPROVED_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"create_gmail_draft",
|
||||
|
|
|
|||
|
|
@ -99,11 +99,11 @@ async def write_cached_tools(
|
|||
|
||||
def refresh_mcp_tools_cache_for_connector(
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
) -> None:
|
||||
"""Maintain the MCP tool cache after a single-connector lifecycle event.
|
||||
|
||||
Synchronously evicts the in-process LRU for the connector's search space
|
||||
Synchronously evicts the in-process LRU for the connector's workspace
|
||||
(LRU keys are per-space, so eviction cannot be scoped finer), then schedules
|
||||
a background live discovery for this connector alone so its persisted
|
||||
``cached_tools`` row is refreshed before the next user query.
|
||||
|
|
@ -116,11 +116,11 @@ def refresh_mcp_tools_cache_for_connector(
|
|||
invalidate_mcp_tools_cache,
|
||||
)
|
||||
|
||||
invalidate_mcp_tools_cache(search_space_id)
|
||||
invalidate_mcp_tools_cache(workspace_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP in-process cache eviction skipped for space %d",
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ _MCP_CACHE_MAX_SIZE = 50
|
|||
_MCP_DISCOVERY_TIMEOUT_SECONDS = 30
|
||||
_TOOL_CALL_MAX_RETRIES = 3
|
||||
_TOOL_CALL_RETRY_DELAY = 1.5 # seconds, doubles per attempt
|
||||
# Keyed by ``(search_space_id, bypass_internal_hitl)`` so single-agent and
|
||||
# Keyed by ``(workspace_id, bypass_internal_hitl)`` so single-agent and
|
||||
# multi-agent paths cannot share tool closures with different HITL wiring.
|
||||
_MCPCacheKey = tuple[int, bool]
|
||||
_mcp_tools_cache: dict[_MCPCacheKey, tuple[float, list[StructuredTool]]] = {}
|
||||
|
|
@ -649,14 +649,31 @@ async def _load_http_mcp_tools(
|
|||
total_discovered = len(tool_definitions)
|
||||
|
||||
if allowed_set:
|
||||
tool_definitions = [td for td in tool_definitions if td["name"] in allowed_set]
|
||||
logger.info(
|
||||
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
|
||||
url,
|
||||
connector_id,
|
||||
len(tool_definitions),
|
||||
total_discovered,
|
||||
)
|
||||
filtered = [td for td in tool_definitions if td["name"] in allowed_set]
|
||||
if not filtered and total_discovered:
|
||||
# The server renamed its tools out from under our allowlist
|
||||
# (e.g. Notion's "notion-" prefix rename) — a fully-dead
|
||||
# allowlist would silently disable the connector. Load
|
||||
# everything instead: renamed tools won't match
|
||||
# ``readonly_tools`` either, so every tool stays HITL-gated.
|
||||
logger.warning(
|
||||
"HTTP MCP server '%s' (connector %d): allowlist matched 0/%d "
|
||||
"advertised tools — server likely renamed its tools. "
|
||||
"Loading all tools (HITL-gated). Update the registry allowlist: %s",
|
||||
url,
|
||||
connector_id,
|
||||
total_discovered,
|
||||
sorted(td["name"] for td in tool_definitions),
|
||||
)
|
||||
else:
|
||||
tool_definitions = filtered
|
||||
logger.info(
|
||||
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
|
||||
url,
|
||||
connector_id,
|
||||
len(tool_definitions),
|
||||
total_discovered,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Discovered %d tools from HTTP MCP server '%s' (connector %d) — no allowlist, loading all",
|
||||
|
|
@ -814,7 +831,7 @@ async def _refresh_connector_token(
|
|||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
|
||||
invalidate_mcp_tools_cache(connector.search_space_id)
|
||||
invalidate_mcp_tools_cache(connector.workspace_id)
|
||||
|
||||
return new_access
|
||||
|
||||
|
|
@ -990,7 +1007,7 @@ async def _mark_connector_auth_expired(connector_id: int) -> None:
|
|||
"Marked MCP connector %s as auth_expired after unrecoverable 401",
|
||||
connector_id,
|
||||
)
|
||||
invalidate_mcp_tools_cache(connector.search_space_id)
|
||||
invalidate_mcp_tools_cache(connector.workspace_id)
|
||||
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
|
@ -1000,10 +1017,10 @@ async def _mark_connector_auth_expired(connector_id: int) -> None:
|
|||
)
|
||||
|
||||
|
||||
def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None:
|
||||
def invalidate_mcp_tools_cache(workspace_id: int | None = None) -> None:
|
||||
"""Invalidate cached MCP tools (both ``bypass_internal_hitl`` variants together)."""
|
||||
if search_space_id is not None:
|
||||
for key in [k for k in _mcp_tools_cache if k[0] == search_space_id]:
|
||||
if workspace_id is not None:
|
||||
for key in [k for k in _mcp_tools_cache if k[0] == workspace_id]:
|
||||
_mcp_tools_cache.pop(key, None)
|
||||
else:
|
||||
_mcp_tools_cache.clear()
|
||||
|
|
@ -1099,27 +1116,27 @@ async def discover_single_mcp_connector(connector_id: int) -> None:
|
|||
|
||||
async def load_mcp_tools(
|
||||
session: AsyncSession,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
*,
|
||||
bypass_internal_hitl: bool = False,
|
||||
) -> list[StructuredTool]:
|
||||
"""Load all MCP tools from the user's active MCP server connectors.
|
||||
|
||||
Results are cached per ``(search_space_id, bypass_internal_hitl)`` for up
|
||||
Results are cached per ``(workspace_id, bypass_internal_hitl)`` for up
|
||||
to 5 minutes; bypass is keyed because each variant builds a different tool
|
||||
closure (with vs. without the in-wrapper ``request_approval`` gate).
|
||||
"""
|
||||
_evict_expired_mcp_cache()
|
||||
|
||||
now = time.monotonic()
|
||||
cache_key: _MCPCacheKey = (search_space_id, bypass_internal_hitl)
|
||||
cache_key: _MCPCacheKey = (workspace_id, bypass_internal_hitl)
|
||||
cached = _mcp_tools_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_at, cached_tools = cached
|
||||
if now - cached_at < _MCP_CACHE_TTL_SECONDS:
|
||||
logger.info(
|
||||
"Using cached MCP tools for search space %s (%d tools, age=%.0fs, bypass_hitl=%s)",
|
||||
search_space_id,
|
||||
"Using cached MCP tools for workspace %s (%d tools, age=%.0fs, bypass_hitl=%s)",
|
||||
workspace_id,
|
||||
len(cached_tools),
|
||||
now - cached_at,
|
||||
bypass_internal_hitl,
|
||||
|
|
@ -1132,7 +1149,7 @@ async def load_mcp_tools(
|
|||
# Cast JSON -> JSONB so we can use has_key to filter by the presence of "server_config".
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id,
|
||||
SearchSourceConnector.workspace_id == workspace_id,
|
||||
cast(SearchSourceConnector.config, JSONB).has_key("server_config"),
|
||||
),
|
||||
)
|
||||
|
|
@ -1322,9 +1339,9 @@ async def load_mcp_tools(
|
|||
del _mcp_tools_cache[oldest_key]
|
||||
|
||||
logger.info(
|
||||
"Loaded %d MCP tools for search space %d (bypass_hitl=%s)",
|
||||
"Loaded %d MCP tools for workspace %d (bypass_hitl=%s)",
|
||||
len(tools),
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
bypass_internal_hitl,
|
||||
)
|
||||
return tools
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads."""
|
||||
"""Image generation via litellm; resolves model config from the workspace and returns UI-ready payloads."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
|
@ -18,7 +18,7 @@ from app.config import config
|
|||
from app.db import (
|
||||
ImageGeneration,
|
||||
Model,
|
||||
SearchSpace,
|
||||
Workspace,
|
||||
shielded_async_session,
|
||||
)
|
||||
from app.services.auto_model_pin_service import (
|
||||
|
|
@ -48,14 +48,14 @@ def _get_global_connection(connection_id: int) -> dict | None:
|
|||
|
||||
|
||||
def create_generate_image_tool(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
db_session: AsyncSession,
|
||||
image_gen_model_id_override: int | None = None,
|
||||
):
|
||||
"""Create ``generate_image`` with bound search space; DB work uses a per-call session.
|
||||
"""Create ``generate_image`` with bound workspace; DB work uses a per-call session.
|
||||
|
||||
``image_gen_model_id_override``: when set (automations running on a
|
||||
captured model), use this model id instead of reading the search space's
|
||||
captured model), use this model id instead of reading the workspace's
|
||||
live ``image_gen_model_id``.
|
||||
"""
|
||||
del db_session # tool uses a fresh per-call session instead
|
||||
|
|
@ -102,23 +102,21 @@ def create_generate_image_tool(
|
|||
# autoflushes from a concurrent writer poison this tool too.
|
||||
async with shielded_async_session() as session:
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||
select(Workspace).filter(Workspace.id == workspace_id)
|
||||
)
|
||||
search_space = result.scalars().first()
|
||||
if not search_space:
|
||||
workspace = result.scalars().first()
|
||||
if not workspace:
|
||||
return _failed(
|
||||
{"error": "Search space not found"},
|
||||
error="Search space not found",
|
||||
{"error": "Workspace not found"},
|
||||
error="Workspace not found",
|
||||
)
|
||||
|
||||
if image_gen_model_id_override is not None:
|
||||
# Automation run: use the captured image model, insulated from
|
||||
# later search-space changes. No search-space read needed.
|
||||
# later workspace changes. No workspace read needed.
|
||||
config_id = image_gen_model_id_override or IMAGE_GEN_AUTO_MODE_ID
|
||||
else:
|
||||
config_id = (
|
||||
search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
|
||||
)
|
||||
config_id = workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
|
||||
|
||||
# size/quality/style are intentionally omitted: valid values
|
||||
# differ per model, so we let each model use its own defaults.
|
||||
|
|
@ -129,8 +127,8 @@ def create_generate_image_tool(
|
|||
if is_image_gen_auto_mode(config_id):
|
||||
candidates = await auto_model_candidates(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
user_id=search_space.user_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=workspace.user_id,
|
||||
capability="image_gen",
|
||||
)
|
||||
if not candidates:
|
||||
|
|
@ -140,7 +138,7 @@ def create_generate_image_tool(
|
|||
)
|
||||
return _failed({"error": err}, error=err)
|
||||
config_id = int(
|
||||
choose_auto_model_candidate(candidates, search_space_id)["id"]
|
||||
choose_auto_model_candidate(candidates, workspace_id)["id"]
|
||||
)
|
||||
|
||||
provider_base_url: str | None = None
|
||||
|
|
@ -186,15 +184,12 @@ def create_generate_image_tool(
|
|||
return _failed({"error": err}, error=err)
|
||||
conn = db_model.connection
|
||||
if (
|
||||
conn.search_space_id is not None
|
||||
and conn.search_space_id != search_space_id
|
||||
conn.workspace_id is not None
|
||||
and conn.workspace_id != workspace_id
|
||||
):
|
||||
err = f"Image generation model {config_id} not found"
|
||||
return _failed({"error": err}, error=err)
|
||||
if (
|
||||
conn.user_id is not None
|
||||
and conn.user_id != search_space.user_id
|
||||
):
|
||||
if conn.user_id is not None and conn.user_id != workspace.user_id:
|
||||
err = f"Image generation model {config_id} not found"
|
||||
return _failed({"error": err}, error=err)
|
||||
if not has_capability(db_model, "image_gen"):
|
||||
|
|
@ -225,7 +220,7 @@ def create_generate_image_tool(
|
|||
n=n,
|
||||
image_gen_model_id=config_id,
|
||||
response_data=response_dict,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
access_token=access_token,
|
||||
)
|
||||
session.add(db_image_gen)
|
||||
|
|
|
|||
|
|
@ -28,28 +28,28 @@ def load_tools(
|
|||
d = {**(dependencies or {}), **kwargs}
|
||||
return [
|
||||
create_generate_podcast_tool(
|
||||
search_space_id=d["search_space_id"],
|
||||
workspace_id=d["workspace_id"],
|
||||
db_session=d["db_session"],
|
||||
thread_id=d["thread_id"],
|
||||
),
|
||||
create_generate_video_presentation_tool(
|
||||
search_space_id=d["search_space_id"],
|
||||
workspace_id=d["workspace_id"],
|
||||
db_session=d["db_session"],
|
||||
thread_id=d["thread_id"],
|
||||
),
|
||||
create_generate_report_tool(
|
||||
search_space_id=d["search_space_id"],
|
||||
workspace_id=d["workspace_id"],
|
||||
thread_id=d["thread_id"],
|
||||
connector_service=d.get("connector_service"),
|
||||
available_connectors=d.get("available_connectors"),
|
||||
available_document_types=d.get("available_document_types"),
|
||||
),
|
||||
create_generate_resume_tool(
|
||||
search_space_id=d["search_space_id"],
|
||||
workspace_id=d["workspace_id"],
|
||||
thread_id=d["thread_id"],
|
||||
),
|
||||
create_generate_image_tool(
|
||||
search_space_id=d["search_space_id"],
|
||||
workspace_id=d["workspace_id"],
|
||||
db_session=d["db_session"],
|
||||
image_gen_model_id_override=d.get("image_gen_model_id_override"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def create_generate_podcast_tool(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
db_session: AsyncSession,
|
||||
thread_id: int | None = None,
|
||||
):
|
||||
"""Create ``generate_podcast`` with bound search space and thread; DB writes use a tool-local session."""
|
||||
"""Create ``generate_podcast`` with bound workspace and thread; DB writes use a tool-local session."""
|
||||
del db_session # writes use a fresh tool-local session, see below
|
||||
|
||||
@tool
|
||||
|
|
@ -77,13 +77,13 @@ def create_generate_podcast_tool(
|
|||
service = PodcastService(session)
|
||||
podcast = await service.create(
|
||||
title=podcast_title,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=resolve_root_thread_id(runtime, thread_id),
|
||||
)
|
||||
podcast.source_content = source_content
|
||||
spec = await propose_brief(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
focus=user_prompt,
|
||||
)
|
||||
await service.attach_brief(podcast, spec)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def _report_search_types(
|
|||
"""Build the document-type scope for the shared KB search.
|
||||
|
||||
``None`` means "search every indexed type"; a tuple narrows the scope to the
|
||||
connectors/document types the search space actually has.
|
||||
connectors/document types the workspace actually has.
|
||||
"""
|
||||
types: set[str] = set()
|
||||
if available_document_types:
|
||||
|
|
@ -561,7 +561,7 @@ async def _revise_with_sections(
|
|||
|
||||
|
||||
def create_generate_report_tool(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
thread_id: int | None = None,
|
||||
connector_service: ConnectorService | None = None,
|
||||
available_connectors: list[str] | None = None,
|
||||
|
|
@ -728,7 +728,7 @@ def create_generate_report_tool(
|
|||
"error_message": error_msg,
|
||||
},
|
||||
report_style=report_style,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=resolve_root_thread_id(runtime, thread_id),
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
|
|
@ -769,7 +769,7 @@ def create_generate_report_tool(
|
|||
"creating standalone report"
|
||||
)
|
||||
|
||||
llm = await get_agent_llm(read_session, search_space_id)
|
||||
llm = await get_agent_llm(read_session, workspace_id)
|
||||
|
||||
if not llm:
|
||||
error_msg = (
|
||||
|
|
@ -846,7 +846,7 @@ def create_generate_report_tool(
|
|||
async with shielded_async_session() as kb_session:
|
||||
return await search_chunks(
|
||||
kb_session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
query=q,
|
||||
scope=scope,
|
||||
top_k=10,
|
||||
|
|
@ -1044,7 +1044,7 @@ def create_generate_report_tool(
|
|||
content=report_content,
|
||||
report_metadata=metadata,
|
||||
report_style=report_style,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=resolve_root_thread_id(runtime, thread_id),
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -421,7 +421,7 @@ def _validate_max_pages(max_pages: int) -> int:
|
|||
|
||||
|
||||
def create_generate_resume_tool(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
thread_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
|
|
@ -531,7 +531,7 @@ def create_generate_resume_tool(
|
|||
"error_message": error_msg,
|
||||
},
|
||||
report_style="resume",
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=resolve_root_thread_id(runtime, thread_id),
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
|
|
@ -581,7 +581,7 @@ def create_generate_resume_tool(
|
|||
f"(group {report_group_id})"
|
||||
)
|
||||
|
||||
llm = await get_agent_llm(read_session, search_space_id)
|
||||
llm = await get_agent_llm(read_session, workspace_id)
|
||||
|
||||
if not llm:
|
||||
error_msg = (
|
||||
|
|
@ -819,7 +819,7 @@ def create_generate_resume_tool(
|
|||
content_type="typst",
|
||||
report_metadata=metadata,
|
||||
report_style="resume",
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=resolve_root_thread_id(runtime, thread_id),
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def create_generate_video_presentation_tool(
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
db_session: AsyncSession,
|
||||
thread_id: int | None = None,
|
||||
):
|
||||
"""Create ``generate_video_presentation`` with bound search space and thread; writes use a tool-local session."""
|
||||
"""Create ``generate_video_presentation`` with bound workspace and thread; writes use a tool-local session."""
|
||||
del db_session # writes use a fresh tool-local session, see below
|
||||
|
||||
@tool
|
||||
|
|
@ -60,7 +60,7 @@ def create_generate_video_presentation_tool(
|
|||
video_pres = VideoPresentation(
|
||||
title=video_title,
|
||||
status=VideoPresentationStatus.PENDING,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=resolve_root_thread_id(runtime, thread_id),
|
||||
)
|
||||
session.add(video_pres)
|
||||
|
|
@ -75,7 +75,7 @@ def create_generate_video_presentation_tool(
|
|||
task = generate_video_presentation_task.delay(
|
||||
video_presentation_id=video_pres_id,
|
||||
source_content=source_content,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""``google_maps`` builtin subagent: structured Google Maps place/review data."""
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
"""``luma`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
|
||||
|
||||
Tools self-gate inside their bodies via :func:`request_approval`; the
|
||||
empty :data:`tools.index.RULESET` is layered into a per-subagent
|
||||
:class:`PermissionMiddleware` for uniformity.
|
||||
"""
|
||||
"""``google_maps`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -33,7 +28,7 @@ def build_subagent(
|
|||
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
|
||||
description = (
|
||||
read_md_file(__package__, "description").strip()
|
||||
or "Handles luma tasks for this workspace."
|
||||
or "Pulls structured data from Google Maps — places and their reviews."
|
||||
)
|
||||
system_prompt = read_md_file(__package__, "system_prompt").strip()
|
||||
return pack_subagent(
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Google Maps specialist: pulls structured data from Google Maps — finds places by search query (optionally scoped to a location), or resolves a Maps URL / place ID into place details (name, address, category, phone, website, rating, review count, coordinates, opening hours), and fetches a place's reviews (author, text, star rating, owner response, dates). Also compares fresh Maps data against earlier findings in this chat.
|
||||
Use whenever the task involves Google Maps places, local businesses, or a google.com/maps link. Triggers include "find <business type> near/in X", "get details for this place", "phone/address/hours/rating of X", "how many reviews", "get the reviews for this place", "what are people saying about this business", and comparisons against earlier Maps results in this chat. Not for general web pages (use the web crawling specialist) or YouTube (use the YouTube specialist).
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
You are the SurfSense Google Maps sub-agent.
|
||||
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
|
||||
|
||||
<goal>
|
||||
Answer the delegated question from live Google Maps data gathered with your verbs, comparing against earlier results already in this conversation when the task calls for it.
|
||||
</goal>
|
||||
|
||||
<available_tools>
|
||||
- `google_maps_scrape`
|
||||
- `google_maps_reviews`
|
||||
- `read_run` / `search_run` (free readers for stored scrape output)
|
||||
</available_tools>
|
||||
|
||||
<playbook>
|
||||
- Finding places on a topic: call `google_maps_scrape` with `search_queries`, adding `location` to scope them (e.g. "coffee shops" in "Austin, USA").
|
||||
- Known place links or IDs: call `google_maps_scrape` with the links in `urls` or the IDs in `place_ids`.
|
||||
- Need richer detail (opening hours, popular times, extra contact info): set `include_details=true`.
|
||||
- Reviews / sentiment on specific places: call `google_maps_reviews` with the place `urls` or `place_ids`.
|
||||
- Batch multiple queries, URLs, or place IDs into one call rather than many single-item calls.
|
||||
- Exclusion criteria are strict: when the task excludes a category or brand, drop every place whose name, website domain, or category matches it — a local branch or satellite location of an excluded chain/system is still that chain, never reinterpret it as acceptable. Fill the gap with more or wider queries instead.
|
||||
- The website domain is an ownership signal: a place whose site lives on a parent organization's domain (a government portal, a chain's site, a health-system or franchise domain) rather than its own belongs to that parent — judge include/exclude criteria against the parent, and treat multiple locations sharing one parent domain as one organization.
|
||||
<include snippet="run_reader"/>
|
||||
- Comparison requests: pull the current values, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, old -> new).
|
||||
</playbook>
|
||||
|
||||
<tool_policy>
|
||||
- Use only tools in `<available_tools>`.
|
||||
- An item whose `status` is not `success` returned no data — report it unavailable, never invent it.
|
||||
- Report only deltas you can point to in the evidence. Never fabricate facts, counts, quotes, ratings, or URLs.
|
||||
</tool_policy>
|
||||
|
||||
<out_of_scope>
|
||||
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
|
||||
- Non-Maps web pages belong to the web crawling specialist; YouTube belongs to the YouTube specialist.
|
||||
</out_of_scope>
|
||||
|
||||
<safety>
|
||||
- Report uncertainty explicitly when evidence is incomplete or conflicting.
|
||||
- Never present unverified claims as facts.
|
||||
</safety>
|
||||
|
||||
<failure_policy>
|
||||
- Underspecified request — no usable search query, URL, or place ID — return `status=blocked` with the missing fields.
|
||||
- Tool failure: return `status=error` with a concise recovery `next_step`.
|
||||
- No useful evidence: return `status=blocked` with a narrower query or the URLs/IDs you still need.
|
||||
</failure_policy>
|
||||
|
||||
<output_contract>
|
||||
Return **only** one JSON object (no markdown/prose):
|
||||
{
|
||||
"status": "success" | "partial" | "blocked" | "error",
|
||||
"action_summary": string,
|
||||
"evidence": {
|
||||
"findings": string[],
|
||||
"sources": string[],
|
||||
"confidence": "high" | "medium" | "low"
|
||||
},
|
||||
"next_step": string | null,
|
||||
"missing_fields": string[] | null,
|
||||
"assumptions": string[] | null
|
||||
}
|
||||
<include snippet="output_contract_base"/>
|
||||
Route-specific rules:
|
||||
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct fact or delta. Do not paste raw payloads.
|
||||
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
|
||||
</output_contract>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""``google_maps`` sub-agent tools: the Google Maps scrape + reviews capability verbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
|
||||
from app.capabilities.core.access.agent import build_capability_tools
|
||||
from app.capabilities.google_maps.reviews.definition import GOOGLE_MAPS_REVIEWS
|
||||
from app.capabilities.google_maps.scrape.definition import GOOGLE_MAPS_SCRAPE
|
||||
|
||||
NAME = "google_maps"
|
||||
|
||||
RULESET = Ruleset(origin=NAME, rules=[])
|
||||
|
||||
_CI_VERBS = [GOOGLE_MAPS_SCRAPE, GOOGLE_MAPS_REVIEWS]
|
||||
|
||||
|
||||
def load_tools(
|
||||
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
|
||||
) -> list[BaseTool]:
|
||||
d = {**(dependencies or {}), **kwargs}
|
||||
return build_capability_tools(
|
||||
workspace_id=d.get("workspace_id"),
|
||||
capabilities=_CI_VERBS,
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""``google_search`` builtin subagent: structured Google Search results."""
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
"""``gmail`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
|
||||
|
||||
Tools self-gate inside their bodies via :func:`request_approval`; the
|
||||
empty :data:`tools.index.RULESET` is layered into a per-subagent
|
||||
:class:`PermissionMiddleware` for uniformity.
|
||||
"""
|
||||
"""``google_search`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -33,7 +28,7 @@ def build_subagent(
|
|||
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
|
||||
description = (
|
||||
read_md_file(__package__, "description").strip()
|
||||
or "Handles gmail tasks for this workspace."
|
||||
or "Searches Google and returns structured results for a query."
|
||||
)
|
||||
system_prompt = read_md_file(__package__, "system_prompt").strip()
|
||||
return pack_subagent(
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Google Search specialist: runs Google searches and returns structured SERP data — organic results (title, URL, description), related queries, people-also-ask, and any AI overview. Searches by plain query (optionally scoped to a country/language or restricted to a single site) or scrapes a Google Search URL as-is, and can page deeper with max_pages_per_query. Also compares fresh search results against earlier findings in this chat.
|
||||
This is the sole path for public web search: use it for any real-time or public-fact lookup (current events, news, prices, weather, exchange rates, live data) as well as to find pages or sources on the open web via Google, discover which sites rank for a topic, or gather a list of result URLs to hand off for crawling. Triggers include "search Google for X", "what's the current X", "latest news on X", "find websites/pages about X", "who ranks for X", "top results for X", "find X's website", and comparisons against earlier search results in this chat. Not for reading a specific page's content (use the web crawling specialist), Google Maps places (use the Google Maps specialist), or YouTube (use the YouTube specialist).
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
You are the SurfSense Google Search sub-agent.
|
||||
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
|
||||
|
||||
<goal>
|
||||
Answer the delegated question from live Google Search data gathered with your verb, comparing against earlier results already in this conversation when the task calls for it.
|
||||
</goal>
|
||||
|
||||
<available_tools>
|
||||
- `google_search_scrape`
|
||||
- `read_run` / `search_run` (free readers for stored scrape output)
|
||||
</available_tools>
|
||||
|
||||
<playbook>
|
||||
- Finding pages on a topic: call `google_search_scrape` with `queries`, scoping with `country_code`/`language_code` when locale matters.
|
||||
- Restricting to one website: set `site` (e.g. "example.com") to only return results from that domain.
|
||||
- Scraping a specific results page: pass the full Google Search URL in `queries`.
|
||||
- Need more results: raise `max_pages_per_query` to page beyond the first page.
|
||||
- Batch multiple search terms into one call rather than many single-term calls.
|
||||
<include snippet="run_reader"/>
|
||||
- Handing URLs off for crawling: return the organic result URLs so the supervisor can route them to the web crawling specialist.
|
||||
- Comparison requests: pull the current results, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, moved up/down).
|
||||
</playbook>
|
||||
|
||||
<tool_policy>
|
||||
- Use only tools in `<available_tools>`.
|
||||
- Report only results present in the tool output. Never invent titles, URLs, snippets, or rankings.
|
||||
</tool_policy>
|
||||
|
||||
<out_of_scope>
|
||||
- Do not read or extract a specific page's content — return the URLs for the web crawling specialist.
|
||||
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
|
||||
- Google Maps places belong to the Google Maps specialist; YouTube belongs to the YouTube specialist.
|
||||
- Discovering physical businesses or venues of a type in a geography ("find X businesses in Y") is the Google Maps specialist's job — if that is the whole task, return `status=blocked` with a `next_step` pointing the supervisor to the Maps specialist instead of approximating it from search snippets.
|
||||
</out_of_scope>
|
||||
|
||||
<safety>
|
||||
- Report uncertainty explicitly when evidence is incomplete or conflicting.
|
||||
- Never present unverified claims as facts.
|
||||
</safety>
|
||||
|
||||
<failure_policy>
|
||||
- Underspecified request — no usable query or URL — return `status=blocked` with the missing fields.
|
||||
- Tool failure: return `status=error` with a concise recovery `next_step`.
|
||||
- No useful evidence: return `status=blocked` with a narrower query or the scope you still need.
|
||||
</failure_policy>
|
||||
|
||||
<output_contract>
|
||||
Return **only** one JSON object (no markdown/prose):
|
||||
{
|
||||
"status": "success" | "partial" | "blocked" | "error",
|
||||
"action_summary": string,
|
||||
"evidence": {
|
||||
"findings": string[],
|
||||
"sources": string[],
|
||||
"confidence": "high" | "medium" | "low"
|
||||
},
|
||||
"next_step": string | null,
|
||||
"missing_fields": string[] | null,
|
||||
"assumptions": string[] | null
|
||||
}
|
||||
<include snippet="output_contract_base"/>
|
||||
Route-specific rules:
|
||||
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct result or delta. Do not paste raw payloads.
|
||||
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
|
||||
</output_contract>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"""``google_search`` sub-agent tools: the Google Search scrape capability verb."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
|
||||
from app.capabilities.core.access.agent import build_capability_tools
|
||||
from app.capabilities.google_search.scrape.definition import GOOGLE_SEARCH_SCRAPE
|
||||
|
||||
NAME = "google_search"
|
||||
|
||||
RULESET = Ruleset(origin=NAME, rules=[])
|
||||
|
||||
_CI_VERBS = [GOOGLE_SEARCH_SCRAPE]
|
||||
|
||||
|
||||
def load_tools(
|
||||
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
|
||||
) -> list[BaseTool]:
|
||||
d = {**(dependencies or {}), **kwargs}
|
||||
return build_capability_tools(
|
||||
workspace_id=d.get("workspace_id"),
|
||||
capabilities=_CI_VERBS,
|
||||
)
|
||||
|
|
@ -16,6 +16,9 @@ from langchain_core.tools import BaseTool
|
|||
from app.agents.chat.multi_agent_chat.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.chat.multi_agent_chat.shared.permissions import Rule, Ruleset
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||
append_today_utc,
|
||||
)
|
||||
|
||||
from .middleware_stack import build_kb_middleware
|
||||
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
|
||||
|
|
@ -36,7 +39,7 @@ _KB_READONLY_RULESET = Ruleset(origin=READONLY_NAME, rules=[])
|
|||
def _build_search_knowledge_base_tool(dependencies: dict[str, Any]) -> BaseTool:
|
||||
"""Construct the hybrid-RAG ``search_knowledge_base`` tool from shared deps."""
|
||||
return create_search_knowledge_base_tool(
|
||||
search_space_id=dependencies["search_space_id"],
|
||||
workspace_id=dependencies["workspace_id"],
|
||||
available_connectors=dependencies.get("available_connectors"),
|
||||
available_document_types=dependencies.get("available_document_types"),
|
||||
)
|
||||
|
|
@ -57,7 +60,7 @@ def build_subagent(
|
|||
{
|
||||
"name": NAME,
|
||||
"description": load_description(),
|
||||
"system_prompt": load_system_prompt(filesystem_mode),
|
||||
"system_prompt": append_today_utc(load_system_prompt(filesystem_mode)),
|
||||
"model": llm,
|
||||
"tools": [_build_search_knowledge_base_tool(dependencies)],
|
||||
"middleware": build_kb_middleware(
|
||||
|
|
@ -86,7 +89,9 @@ def build_readonly_subagent(
|
|||
{
|
||||
"name": READONLY_NAME,
|
||||
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
|
||||
"system_prompt": load_readonly_system_prompt(filesystem_mode),
|
||||
"system_prompt": append_today_utc(
|
||||
load_readonly_system_prompt(filesystem_mode)
|
||||
),
|
||||
"model": llm,
|
||||
"tools": [_build_search_knowledge_base_tool(dependencies)],
|
||||
"middleware": build_kb_middleware(
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ def build_kb_middleware(
|
|||
fs_mw = build_filesystem_mw(
|
||||
backend_resolver=dependencies["backend_resolver"],
|
||||
filesystem_mode=filesystem_mode,
|
||||
search_space_id=dependencies["search_space_id"],
|
||||
workspace_id=dependencies["workspace_id"],
|
||||
user_id=dependencies.get("user_id"),
|
||||
thread_id=dependencies.get("thread_id"),
|
||||
read_only=read_only,
|
||||
|
|
|
|||
|
|
@ -38,13 +38,15 @@ _DEFAULT_TOP_K = 5
|
|||
_MAX_TOP_K = 20
|
||||
|
||||
_TOOL_DESCRIPTION = (
|
||||
"Search the user's knowledge base (their indexed documents, files, and "
|
||||
"connector content) for passages relevant to a query, using hybrid "
|
||||
"semantic + keyword retrieval.\n\n"
|
||||
"Search the user's knowledge base — their own uploaded files, documents, "
|
||||
"and notes — for passages relevant to a query, using hybrid semantic + "
|
||||
"keyword retrieval.\n\n"
|
||||
"Use this FIRST to ground any factual or informational answer about the "
|
||||
"user's own documents, notes, or connected sources. It returns a "
|
||||
"<retrieved_context> block: each matched passage is labelled [n]. Cite a "
|
||||
"passage by writing that [n] after the statement it supports.\n\n"
|
||||
"user's personal files and notes. It returns a <retrieved_context> block: "
|
||||
"each matched passage is labelled [n]. Cite a passage by writing that [n] "
|
||||
"after the statement it supports.\n\n"
|
||||
"This searches only the user's stored files and notes — live data in "
|
||||
"connected apps (Slack, Jira, Notion, Gmail, etc.) is not indexed here.\n\n"
|
||||
"Write a focused, specific query containing the concrete entities, "
|
||||
"acronyms, people, projects, or terms you are looking for."
|
||||
)
|
||||
|
|
@ -89,7 +91,7 @@ def _resolve_mention_pins(
|
|||
async def _build_search_scope(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
document_types: tuple[str, ...] | None,
|
||||
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||
) -> SearchScope:
|
||||
|
|
@ -97,7 +99,7 @@ async def _build_search_scope(
|
|||
mentioned_document_ids, mentioned_folder_ids = _resolve_mention_pins(runtime)
|
||||
document_ids = await referenced_document_ids(
|
||||
session,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
document_ids=mentioned_document_ids,
|
||||
folder_ids=mentioned_folder_ids,
|
||||
)
|
||||
|
|
@ -109,13 +111,13 @@ async def _build_search_scope(
|
|||
|
||||
def create_search_knowledge_base_tool(
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
available_connectors: list[str] | None = None,
|
||||
available_document_types: list[str] | None = None,
|
||||
) -> BaseTool:
|
||||
"""Factory for the on-demand ``search_knowledge_base`` tool."""
|
||||
|
||||
_space_id = search_space_id
|
||||
_space_id = workspace_id
|
||||
_document_types = _search_types(available_connectors, available_document_types)
|
||||
|
||||
async def _impl(
|
||||
|
|
@ -140,13 +142,13 @@ def create_search_knowledge_base_tool(
|
|||
async with shielded_async_session() as session:
|
||||
scope = await _build_search_scope(
|
||||
session,
|
||||
search_space_id=_space_id,
|
||||
workspace_id=_space_id,
|
||||
document_types=_document_types,
|
||||
runtime=runtime,
|
||||
)
|
||||
hits = await search_chunks(
|
||||
session,
|
||||
search_space_id=_space_id,
|
||||
workspace_id=_space_id,
|
||||
query=cleaned_query,
|
||||
scope=scope,
|
||||
top_k=clamped_top_k,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
"""``mcp_discovery`` builtin subagent: the user's connected apps via MCP.
|
||||
|
||||
Consolidates every MCP-backed connector (Slack, Jira, Linear, ClickUp,
|
||||
Airtable, Notion, Confluence, generic user MCP servers) plus the interim
|
||||
native Gmail/Calendar tools behind a single subagent. Tools are injected
|
||||
directly (opencode/hermes pattern) so the tool-name-keyed permission and
|
||||
"Always Allow" systems keep working unchanged.
|
||||
"""
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""``mcp_discovery`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
|
||||
|
||||
Consolidates every MCP-backed connector plus interim native Gmail/Calendar
|
||||
tools. The permission ruleset is derived from the runtime tool set (not a
|
||||
static constant) because the MCP tool roster is per-user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.permissions import Rule, Ruleset
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import (
|
||||
read_md_file,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||
pack_subagent,
|
||||
)
|
||||
|
||||
from .tools.index import NAME, build_ruleset, load_tools
|
||||
|
||||
|
||||
def _augment_allowlist_for_collision_prefixes(
|
||||
dependencies: dict[str, Any],
|
||||
tools: list[BaseTool],
|
||||
) -> dict[str, Any]:
|
||||
"""Keep "Always Allow" working for tools that got collision-prefixed.
|
||||
|
||||
A tool trusted under its bare name (``search``) can later be renamed to
|
||||
``notion_5_search`` once a second app also exposes ``search`` (Phase 2b
|
||||
collision resolution). The user's persisted allow-rule still keys on the
|
||||
bare name, so we add an alias allow-rule for the new exposed name when the
|
||||
original is already trusted. Returns ``dependencies`` unchanged when there
|
||||
is nothing to do.
|
||||
"""
|
||||
by_subagent = dependencies.get("user_allowlist_by_subagent") or {}
|
||||
existing = by_subagent.get(NAME)
|
||||
if not isinstance(existing, Ruleset) or not existing.rules:
|
||||
return dependencies
|
||||
|
||||
trusted_names = {r.permission for r in existing.rules if r.action == "allow"}
|
||||
alias_rules: list[Rule] = []
|
||||
for tool in tools:
|
||||
meta = getattr(tool, "metadata", None) or {}
|
||||
if not meta.get("mcp_collision_prefixed"):
|
||||
continue
|
||||
original = meta.get("mcp_original_tool_name")
|
||||
if original in trusted_names and tool.name not in trusted_names:
|
||||
alias_rules.append(Rule(permission=tool.name, pattern="*", action="allow"))
|
||||
|
||||
if not alias_rules:
|
||||
return dependencies
|
||||
|
||||
merged = Ruleset(
|
||||
origin=existing.origin,
|
||||
rules=[*existing.rules, *alias_rules],
|
||||
)
|
||||
return {
|
||||
**dependencies,
|
||||
"user_allowlist_by_subagent": {**by_subagent, NAME: merged},
|
||||
}
|
||||
|
||||
|
||||
def build_subagent(
|
||||
*,
|
||||
dependencies: dict[str, Any],
|
||||
model: BaseChatModel | None = None,
|
||||
middleware_stack: dict[str, Any] | None = None,
|
||||
mcp_tools: list[BaseTool] | None = None,
|
||||
) -> SurfSenseSubagentSpec:
|
||||
tools = load_tools(dependencies=dependencies, mcp_tools=mcp_tools)
|
||||
ruleset = build_ruleset(tools)
|
||||
dependencies = _augment_allowlist_for_collision_prefixes(dependencies, tools)
|
||||
description = (
|
||||
read_md_file(__package__, "description").strip()
|
||||
or "Acts on the user's connected apps (Slack, Jira, Notion, Gmail, ...) via MCP."
|
||||
)
|
||||
system_prompt = read_md_file(__package__, "system_prompt").strip()
|
||||
return pack_subagent(
|
||||
name=NAME,
|
||||
description=description,
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
ruleset=ruleset,
|
||||
dependencies=dependencies,
|
||||
model=model,
|
||||
middleware_stack=middleware_stack,
|
||||
)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Connected-apps specialist: acts on the user's connected third-party services through MCP and native integrations — Slack, Jira, Confluence, Linear, ClickUp, Airtable, Notion, Gmail, Google Calendar, and any generic MCP server the user has added. Searches, reads, creates, and updates records in those services (send/read email, manage calendar events, search issues/pages/messages, create or edit issues and pages, query bases, etc.), asking for confirmation before write actions.
|
||||
Use whenever the task is to look something up in, or take an action on, one of the user's connected apps: "search my Slack for X", "create a Jira issue", "what's on my calendar", "draft an email to Y", "find the Notion page about Z", "update the Linear issue". Call `get_connected_accounts` first when it's unclear which account or workspace to act on, or which apps are even connected. Not for reading the user's own uploaded files or notes (use the knowledge base specialist), general web pages (web crawler), or platform scrapers like Reddit/YouTube.
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
You are the SurfSense connected-apps specialist.
|
||||
You act on the user's connected third-party services (Slack, Jira, Confluence, Linear, ClickUp, Airtable, Notion, Gmail, Google Calendar, and any generic MCP servers) on behalf of a supervisor agent, and return structured results for supervisor synthesis.
|
||||
|
||||
<goal>
|
||||
Complete the delegated task against the user's connected apps using only the tools present in your runtime tool list, discovering any identifier or scope the request leaves unspecified before acting.
|
||||
</goal>
|
||||
|
||||
<tools>
|
||||
Your available tools are injected at runtime from whatever apps the user has connected — the set changes per user, so read the tool list and each tool's description rather than assuming a fixed roster. Tools are grouped by app; a tool's description names its MCP server or account.
|
||||
|
||||
- `get_connected_accounts`: lists the user's connected apps and account metadata (workspace/team/site names, ids). Read-only. Call it first whenever it is unclear which apps are connected, or which account/workspace/site an action should target.
|
||||
- Tool names are normally the app's native tool names (e.g. `searchJiraIssuesUsingJql`, `create-pages`, `send_gmail_email`). When the same tool name exists on more than one connected app, the colliding tools are disambiguated with a `{app}_{id}_` prefix and their descriptions carry an `[Account: ...]` or `[MCP server: ...]` tag — pick the one whose tag matches the intended account.
|
||||
</tools>
|
||||
|
||||
<playbook>
|
||||
1. Read the supervisor's request and your runtime tool list. Identify which tools are discovery (list/get/search) and which are mutations (create/update/send/delete) from their descriptions.
|
||||
2. If the request does not pin down the target app, account, or scope, call `get_connected_accounts` (and discovery tools) to resolve it instead of asking the supervisor.
|
||||
3. Run the minimum discovery chain needed to resolve identifiers, then perform the requested action.
|
||||
</playbook>
|
||||
|
||||
<resolution_principle>
|
||||
Proactively look up any identifier, name, value, or scope the request leaves unspecified — target ids, workspace/team/site ids, user ids, page/issue ids, channel names — using discovery tools rather than asking the supervisor. Most requests reference targets by title or paraphrase, not by id. Search for them.
|
||||
|
||||
When discovery for a single slot returns multiple plausible candidates and you cannot confidently pick one, return `status=blocked` with up to 5 options in `evidence.matched_candidates` and the unresolved slot in `missing_fields`. When discovery returns zero matches for a required slot, return `status=blocked` with a `next_step` suggesting alternative filters.
|
||||
</resolution_principle>
|
||||
|
||||
<mutation_guardrails>
|
||||
- Resolve every required id via discovery before calling a mutation tool. Chain discovery calls when a mutation has dependencies (e.g. resolve the site/team before creating within it).
|
||||
- Never invent ids, names, or mutation outcomes. Every field in `evidence` must come from a tool result.
|
||||
- Write tools ask the user for approval before running. If a mutation is approval-rejected (HITL), return `status=blocked` with `next_step="user declined; do not retry"`.
|
||||
- One operation per delegation. For multi-mutation requests, complete the highest-priority one and return `status=partial` with the remainder in `next_step`.
|
||||
</mutation_guardrails>
|
||||
|
||||
<tool_policy>
|
||||
- Use only tools present in your runtime tool list. If no connected app can serve the request, return `status=blocked` explaining which app the user would need to connect.
|
||||
- Report only results present in tool output. Never fabricate records, ids, or messages.
|
||||
</tool_policy>
|
||||
|
||||
<failure_policy>
|
||||
- Underspecified request with no resolvable target: return `status=blocked` with the missing fields.
|
||||
- Tool failure: return `status=error` with the underlying message in `action_summary` and a concise recovery in `next_step`.
|
||||
- No useful evidence after reasonable narrowing: return `status=blocked` with filter suggestions.
|
||||
</failure_policy>
|
||||
|
||||
<output_contract>
|
||||
Return **only** one JSON object (no markdown, no prose):
|
||||
{
|
||||
"status": "success" | "partial" | "blocked" | "error",
|
||||
"action_summary": string,
|
||||
"evidence": {
|
||||
"findings": string[],
|
||||
"sources": string[],
|
||||
"matched_candidates": [
|
||||
{ "id": string, "label": string }
|
||||
] | null,
|
||||
"confidence": "high" | "medium" | "low"
|
||||
},
|
||||
"next_step": string | null,
|
||||
"missing_fields": string[] | null,
|
||||
"assumptions": string[] | null
|
||||
}
|
||||
<include snippet="output_contract_base"/>
|
||||
Route-specific rules:
|
||||
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct record, message, or action result. Do not paste raw payloads.
|
||||
- `evidence.sources`: app URLs or identifiers backing the findings, one per finding when applicable.
|
||||
- For blocked ambiguity, populate `evidence.matched_candidates` with up to 5 options (`id` + `label`).
|
||||
</output_contract>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
"""``mcp_discovery`` subagent tools: interim native Gmail/Calendar, the
|
||||
``get_connected_accounts`` helper, and MCP tools injected at runtime."""
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue