mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/zero-cache-stale-replica-1355
This commit is contained in:
commit
af1d2fa430
601 changed files with 45027 additions and 4681 deletions
27
.github/workflows/docker-build.yml
vendored
27
.github/workflows/docker-build.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
new_tag: ${{ steps.tag_version.outputs.next_version }}
|
new_tag: ${{ steps.tag_version.outputs.next_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event.inputs.branch }}
|
ref: ${{ github.event.inputs.branch }}
|
||||||
|
|
@ -108,16 +108,18 @@ jobs:
|
||||||
name: surfsense-backend
|
name: surfsense-backend
|
||||||
context: ./surfsense_backend
|
context: ./surfsense_backend
|
||||||
file: ./surfsense_backend/Dockerfile
|
file: ./surfsense_backend/Dockerfile
|
||||||
|
target: production
|
||||||
- image: web
|
- image: web
|
||||||
name: surfsense-web
|
name: surfsense-web
|
||||||
context: ./surfsense_web
|
context: ./surfsense_web
|
||||||
file: ./surfsense_web/Dockerfile
|
file: ./surfsense_web/Dockerfile
|
||||||
|
target: runner
|
||||||
env:
|
env:
|
||||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
|
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set lowercase image name
|
- name: Set lowercase image name
|
||||||
id: image
|
id: image
|
||||||
|
|
@ -125,19 +127,19 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.image.outputs.name }}
|
images: ${{ steps.image.outputs.name }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Free up disk space
|
- name: Free up disk space
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -149,10 +151,11 @@ jobs:
|
||||||
|
|
||||||
- name: Build and push by digest ${{ matrix.name }} (${{ matrix.suffix }})
|
- name: Build and push by digest ${{ matrix.name }} (${{ matrix.suffix }})
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
|
target: ${{ matrix.target }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
tags: ${{ steps.image.outputs.name }}
|
tags: ${{ steps.image.outputs.name }}
|
||||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
@ -174,7 +177,7 @@ jobs:
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.image }}-${{ matrix.suffix }}
|
name: digests-${{ matrix.image }}-${{ matrix.suffix }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
|
|
@ -205,22 +208,22 @@ jobs:
|
||||||
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Download amd64 digest
|
- name: Download amd64 digest
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.image }}-amd64
|
name: digests-${{ matrix.image }}-amd64
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
|
|
||||||
- name: Download arm64 digest
|
- name: Download arm64 digest
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.image }}-arm64
|
name: digests-${{ matrix.image }}-arm64
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|
@ -239,7 +242,7 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.image.outputs.name }}
|
images: ${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
|
|
|
||||||
174
.github/workflows/e2e-tests.yml
vendored
Normal file
174
.github/workflows/e2e-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'surfsense_web/**'
|
||||||
|
- 'surfsense_backend/**'
|
||||||
|
- 'docker/docker-compose.e2e.yml'
|
||||||
|
- '.github/workflows/e2e-tests.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
name: Journey
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Test user that the backend creates via /auth/register before Playwright runs.
|
||||||
|
PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net
|
||||||
|
PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123!
|
||||||
|
# Frontend env: Playwright's webServer (surfsense_web/playwright.config.ts)
|
||||||
|
# spawns `pnpm build && pnpm start` in CI; these get baked into the build.
|
||||||
|
NEXT_PUBLIC_FASTAPI_BACKEND_URL: http://localhost:8000
|
||||||
|
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: LOCAL
|
||||||
|
# Shared secret for the test-only POST /__e2e__/auth/token endpoint.
|
||||||
|
# Must match docker-compose.e2e.yml's backend env (x-backend-env).
|
||||||
|
E2E_MINT_SECRET: e2e-mint-secret-not-for-production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
# ─── Backend stack ─────────────────────────────────────────────────
|
||||||
|
# Builds the e2e image (multi-stage, deps cached via GHA), brings up
|
||||||
|
# db + redis + backend + celery_worker, blocks until every healthcheck
|
||||||
|
# is green. No `uv` invocation on the runner; no PID files; no curl
|
||||||
|
# polling loops; readiness is gated by Docker healthchecks.
|
||||||
|
- name: Build & start backend stack
|
||||||
|
run: |
|
||||||
|
docker compose -f docker/docker-compose.e2e.yml \
|
||||||
|
up -d --build --wait --wait-timeout 300
|
||||||
|
|
||||||
|
- name: Show backend stack status
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f docker/docker-compose.e2e.yml ps
|
||||||
|
|
||||||
|
- name: Register E2E test user
|
||||||
|
run: |
|
||||||
|
# 200/201 = created, 400 = already exists (idempotent across reruns).
|
||||||
|
STATUS=$(curl -s -o /tmp/register.json -w "%{http_code}" \
|
||||||
|
-X POST http://localhost:8000/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"${PLAYWRIGHT_TEST_EMAIL}\",\"password\":\"${PLAYWRIGHT_TEST_PASSWORD}\"}")
|
||||||
|
echo "Register status: ${STATUS}"
|
||||||
|
cat /tmp/register.json
|
||||||
|
if [ "${STATUS}" != "200" ] && [ "${STATUS}" != "201" ] && [ "${STATUS}" != "400" ]; then
|
||||||
|
echo "::error::Failed to register test user (status ${STATUS})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Flush auth rate-limit counters so Playwright starts clean.
|
||||||
|
docker compose -f docker/docker-compose.e2e.yml exec -T redis \
|
||||||
|
sh -c "redis-cli --scan --pattern 'surfsense:auth_rate_limit:*' \
|
||||||
|
| xargs -r redis-cli DEL" || true
|
||||||
|
|
||||||
|
# ─── Frontend (host-side) ──────────────────────────────────────────
|
||||||
|
# Playwright's webServer block in playwright.config.ts spawns
|
||||||
|
# `pnpm build && pnpm start` in CI mode and waits for :3000.
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Cache pnpm store
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
working-directory: surfsense_web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: surfsense_web
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Install Playwright system deps (cache hit)
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
working-directory: surfsense_web
|
||||||
|
run: pnpm exec playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Cache Next.js build
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: surfsense_web/.next/cache
|
||||||
|
key: nextjs-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
nextjs-${{ runner.os }}-${{ hashFiles('surfsense_web/pnpm-lock.yaml') }}-
|
||||||
|
nextjs-${{ runner.os }}-
|
||||||
|
|
||||||
|
# ─── Tests ─────────────────────────────────────────────────────────
|
||||||
|
- name: Run Playwright tests
|
||||||
|
working-directory: surfsense_web
|
||||||
|
run: pnpm test:e2e:prod
|
||||||
|
|
||||||
|
# ─── Failure diagnostics ───────────────────────────────────────────
|
||||||
|
- name: Dump backend stack logs on failure
|
||||||
|
if: ${{ failure() || cancelled() }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ./compose-logs
|
||||||
|
docker compose -f docker/docker-compose.e2e.yml logs --no-color --timestamps \
|
||||||
|
> ./compose-logs/all-services.log 2>&1 || true
|
||||||
|
for svc in db redis backend celery_worker; do
|
||||||
|
docker compose -f docker/docker-compose.e2e.yml logs --no-color --timestamps "$svc" \
|
||||||
|
> "./compose-logs/${svc}.log" 2>&1 || true
|
||||||
|
done
|
||||||
|
docker compose -f docker/docker-compose.e2e.yml ps \
|
||||||
|
> ./compose-logs/ps.txt 2>&1 || true
|
||||||
|
|
||||||
|
# ─── Artifacts ─────────────────────────────────────────────────────
|
||||||
|
- name: Upload Playwright HTML report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: surfsense_web/playwright-report/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload Playwright traces
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: playwright-traces
|
||||||
|
path: surfsense_web/test-results/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload backend stack logs
|
||||||
|
if: ${{ failure() || cancelled() }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: backend-stack-logs
|
||||||
|
path: ./compose-logs/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# ─── Teardown ──────────────────────────────────────────────────────
|
||||||
|
- name: Tear down backend stack
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f docker/docker-compose.e2e.yml down -v --remove-orphans
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -17,3 +17,5 @@ surfsense_web/test-results/
|
||||||
surfsense_web/blob-report/
|
surfsense_web/blob-report/
|
||||||
hermes-agent
|
hermes-agent
|
||||||
hermes-agent/
|
hermes-agent/
|
||||||
|
|
||||||
|
content_research/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
# Database, Redis, and internal service wiring are handled automatically.
|
# Database, Redis, and internal service wiring are handled automatically.
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
# SurfSense version (use "latest", a clean version like "0.0.14", or a specific build like "0.0.14.1")
|
# SurfSense version (use "latest" or a specific version like "0.0.14")
|
||||||
SURFSENSE_VERSION=latest
|
SURFSENSE_VERSION=latest
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@
|
||||||
|
|
||||||
name: surfsense-dev
|
name: surfsense-dev
|
||||||
|
|
||||||
|
x-backend-build: &backend-build
|
||||||
|
context: ../surfsense_backend
|
||||||
|
args:
|
||||||
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
|
|
@ -69,7 +74,7 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ../surfsense_backend
|
build: *backend-build
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -114,7 +119,7 @@ services:
|
||||||
start_period: 200s
|
start_period: 200s
|
||||||
|
|
||||||
celery_worker:
|
celery_worker:
|
||||||
build: ../surfsense_backend
|
build: *backend-build
|
||||||
volumes:
|
volumes:
|
||||||
- ../surfsense_backend/app:/app/app
|
- ../surfsense_backend/app:/app/app
|
||||||
- shared_temp:/shared_tmp
|
- shared_temp:/shared_tmp
|
||||||
|
|
@ -140,7 +145,7 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
celery_beat:
|
celery_beat:
|
||||||
build: ../surfsense_backend
|
build: *backend-build
|
||||||
env_file:
|
env_file:
|
||||||
- ../surfsense_backend/.env
|
- ../surfsense_backend/.env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -159,7 +164,7 @@ services:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
# flower:
|
# flower:
|
||||||
# build: ../surfsense_backend
|
# build: *backend-build
|
||||||
# ports:
|
# ports:
|
||||||
# - "${FLOWER_PORT:-5555}:5555"
|
# - "${FLOWER_PORT:-5555}:5555"
|
||||||
# env_file:
|
# env_file:
|
||||||
|
|
|
||||||
181
docker/docker-compose.e2e.yml
Normal file
181
docker/docker-compose.e2e.yml
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# =============================================================================
|
||||||
|
# SurfSense — E2E Docker Compose stack
|
||||||
|
# =============================================================================
|
||||||
|
# Hermetic backend stack for Playwright E2E tests:
|
||||||
|
# - db / redis on an internal-only network (no internet egress)
|
||||||
|
# - backend (FastAPI) joins the internal network AND a separate ingress
|
||||||
|
# bridge so the host runner can reach :8000
|
||||||
|
# - celery_worker on the internal network only — zero egress surface
|
||||||
|
#
|
||||||
|
# The backend image is built from surfsense_backend/Dockerfile target=e2e,
|
||||||
|
# which adds tests/ via the `tests-source` additional context (tests/ is
|
||||||
|
# excluded from the main context by .dockerignore so production never ships
|
||||||
|
# test fakes). See surfsense_backend/Dockerfile for stage layout.
|
||||||
|
#
|
||||||
|
# Usage from repo root:
|
||||||
|
# docker compose -f docker/docker-compose.e2e.yml up -d --build --wait
|
||||||
|
# curl -X POST http://localhost:8000/auth/register ...
|
||||||
|
# ( run Playwright on host, pointing at localhost:8000 + localhost:3000 )
|
||||||
|
# docker compose -f docker/docker-compose.e2e.yml down -v
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
name: surfsense-e2e
|
||||||
|
|
||||||
|
x-backend-env: &backend-env
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/surfsense_e2e
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
|
REDIS_APP_URL: redis://redis:6379/0
|
||||||
|
CELERY_TASK_DEFAULT_QUEUE: surfsense
|
||||||
|
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||||
|
AUTH_TYPE: LOCAL
|
||||||
|
REGISTRATION_ENABLED: "TRUE"
|
||||||
|
ETL_SERVICE: DOCLING
|
||||||
|
EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
|
||||||
|
NEXT_FRONTEND_URL: http://host.docker.internal:3000
|
||||||
|
# Sentinel keys — fakes never read them; turns leaked real calls into 401s.
|
||||||
|
COMPOSIO_API_KEY: e2e-deny-real-call-sentinel
|
||||||
|
COMPOSIO_ENABLED: "TRUE"
|
||||||
|
OPENAI_API_KEY: e2e-deny-real-call-sentinel
|
||||||
|
ANTHROPIC_API_KEY: e2e-deny-real-call-sentinel
|
||||||
|
LITELLM_API_KEY: e2e-deny-real-call-sentinel
|
||||||
|
MICROSOFT_CLIENT_ID: fake-microsoft-client-id
|
||||||
|
MICROSOFT_CLIENT_SECRET: fake-microsoft-client-secret
|
||||||
|
ONEDRIVE_REDIRECT_URI: http://localhost:8000/api/v1/auth/onedrive/connector/callback
|
||||||
|
DROPBOX_APP_KEY: fake-dropbox-app-key
|
||||||
|
DROPBOX_APP_SECRET: fake-dropbox-app-secret
|
||||||
|
DROPBOX_REDIRECT_URI: http://localhost:8000/api/v1/auth/dropbox/connector/callback
|
||||||
|
# Defense-in-depth: even though L3 egress is denied for the worker via
|
||||||
|
# `internal: true`, the backend still has a route via `ingress`. Setting
|
||||||
|
# HTTPS_PROXY to an unreachable port turns any leaked Python outbound HTTP
|
||||||
|
# call into a fast Connection refused. UNLIKE the old runner-shell setup,
|
||||||
|
# this proxy is set on the container env and `uv` is never invoked here,
|
||||||
|
# so there is no interaction with uv's implicit-sync behaviour.
|
||||||
|
HTTPS_PROXY: http://127.0.0.1:1
|
||||||
|
HTTP_PROXY: http://127.0.0.1:1
|
||||||
|
NO_PROXY: localhost,127.0.0.1,0.0.0.0,db,redis,host.docker.internal
|
||||||
|
HF_HUB_OFFLINE: "1"
|
||||||
|
TRANSFORMERS_OFFLINE: "1"
|
||||||
|
# Test-only token-mint endpoint secret (see tests/e2e/run_backend.py).
|
||||||
|
E2E_MINT_SECRET: e2e-mint-secret-not-for-production
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c wal_level=logical
|
||||||
|
-c max_wal_senders=10
|
||||||
|
-c max_replication_slots=10
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: surfsense_e2e
|
||||||
|
# Ephemeral storage — every CI run gets a clean DB, no volume cleanup needed.
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d surfsense_e2e"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ../surfsense_backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: e2e
|
||||||
|
additional_contexts:
|
||||||
|
# tests/ is excluded from the main context by .dockerignore;
|
||||||
|
# the e2e stage's `COPY --from=tests-source` pulls it in here.
|
||||||
|
tests-source: ../surfsense_backend/tests
|
||||||
|
args:
|
||||||
|
EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
|
||||||
|
cache_from:
|
||||||
|
- type=gha,scope=surfsense-e2e-backend
|
||||||
|
cache_to:
|
||||||
|
- type=gha,mode=max,scope=surfsense-e2e-backend
|
||||||
|
image: surfsense-e2e-backend:local
|
||||||
|
environment:
|
||||||
|
<<: *backend-env
|
||||||
|
SERVICE_ROLE: api
|
||||||
|
volumes:
|
||||||
|
- shared_temp:/shared_tmp
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
db: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
healthcheck:
|
||||||
|
# Use Python (already in the image) instead of curl/wget to avoid
|
||||||
|
# depending on either tool being installed in the runtime layers.
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
import sys, urllib.request
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen("http://localhost:8000/openapi.json", timeout=2)
|
||||||
|
sys.exit(0 if r.status == 200 else 1)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 60
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- internal # to reach db/redis
|
||||||
|
- ingress # so host can reach :8000
|
||||||
|
|
||||||
|
celery_worker:
|
||||||
|
image: surfsense-e2e-backend:local
|
||||||
|
pull_policy: never
|
||||||
|
# No build: section — reuses the image built by the `backend` service.
|
||||||
|
# Compose v2 builds shared images exactly once across services that
|
||||||
|
# reference the same `image:` tag.
|
||||||
|
environment:
|
||||||
|
<<: *backend-env
|
||||||
|
SERVICE_ROLE: worker
|
||||||
|
volumes:
|
||||||
|
- shared_temp:/shared_tmp
|
||||||
|
depends_on:
|
||||||
|
backend: { condition: service_healthy }
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- "celery -A app.celery_app inspect ping --timeout 2 | grep -q pong"
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Internal network: containers attached only to this network have NO route
|
||||||
|
# to the host or the internet. This is the L3 deny-egress mechanism that
|
||||||
|
# replaces the fragile HTTPS_PROXY-on-the-runner approach.
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
|
||||||
|
# Regular bridge network. Only the `backend` service joins it, solely so
|
||||||
|
# the host can reach :8000 via the published port. celery_worker / db /
|
||||||
|
# redis stay off this network entirely.
|
||||||
|
ingress:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
shared_temp:
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense",
|
"name": "surfsense",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.24.0"
|
"packageManager": "pnpm@10.26.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
surfsense_backend/.gitignore
vendored
2
surfsense_backend/.gitignore
vendored
|
|
@ -13,5 +13,5 @@ celerybeat-schedule*
|
||||||
celerybeat-schedule.*
|
celerybeat-schedule.*
|
||||||
celerybeat-schedule.dir
|
celerybeat-schedule.dir
|
||||||
celerybeat-schedule.bak
|
celerybeat-schedule.bak
|
||||||
global_llm_config.yaml
|
/app/config/global_llm_config.yaml
|
||||||
app/templates/_generated/
|
app/templates/_generated/
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
FROM python:3.12-slim
|
# =============================================================================
|
||||||
|
# SurfSense Backend — Multi-stage Dockerfile
|
||||||
|
# =============================================================================
|
||||||
|
# Graph: base → deps → models → {e2e, production}
|
||||||
|
# e2e — tests/ via additional_contexts (docker-compose.e2e.yml)
|
||||||
|
# production — published ghcr.io image (docker-build.yml pins target)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ─── Stage 1: base (system deps, Pandoc, certificates) ──────────────────────
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies including SSL tools, CUDA dependencies, and Tesseract OCR
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
|
|
@ -11,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
wget \
|
wget \
|
||||||
unzip \
|
unzip \
|
||||||
gnupg2 \
|
gnupg2 \
|
||||||
|
ffmpeg \
|
||||||
espeak-ng \
|
espeak-ng \
|
||||||
libsndfile1 \
|
libsndfile1 \
|
||||||
libgl1 \
|
libgl1 \
|
||||||
|
|
@ -22,21 +31,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git \
|
git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Pandoc 3.x from GitHub as a fallback for Linux where pypandoc_binary
|
RUN which ffmpeg && ffmpeg -version
|
||||||
# may not bundle pandoc (apt ships 2.17 which has broken table rendering).
|
|
||||||
# pypandoc_binary bundles pandoc on Windows/macOS; on Linux it picks this up.
|
# Pandoc 3.x from GitHub Releases — apt ships 2.17 which has broken table rendering.
|
||||||
|
# pypandoc_binary bundles pandoc on Windows/macOS; on Linux it picks up this binary.
|
||||||
RUN ARCH=$(dpkg --print-architecture) && \
|
RUN ARCH=$(dpkg --print-architecture) && \
|
||||||
wget -qO /tmp/pandoc.deb "https://github.com/jgm/pandoc/releases/download/3.9/pandoc-3.9-1-${ARCH}.deb" && \
|
wget -qO /tmp/pandoc.deb "https://github.com/jgm/pandoc/releases/download/3.9/pandoc-3.9-1-${ARCH}.deb" && \
|
||||||
dpkg -i /tmp/pandoc.deb && \
|
dpkg -i /tmp/pandoc.deb && \
|
||||||
rm /tmp/pandoc.deb
|
rm /tmp/pandoc.deb
|
||||||
|
|
||||||
# Update certificates and install SSL tools
|
|
||||||
RUN update-ca-certificates
|
RUN update-ca-certificates
|
||||||
RUN pip install --upgrade certifi pip-system-certs
|
RUN pip install --upgrade certifi pip-system-certs
|
||||||
|
|
||||||
# Copy requirements
|
ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||||
COPY pyproject.toml .
|
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||||
COPY uv.lock .
|
ENV SURFSENSE_ALLOW_STATIC_FFMPEG_DOWNLOAD=FALSE
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Stage 2: deps (Python deps frozen from uv.lock) ────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
# Install all Python dependencies from uv.lock for deterministic builds.
|
# Install all Python dependencies from uv.lock for deterministic builds.
|
||||||
#
|
#
|
||||||
|
|
@ -49,9 +64,7 @@ COPY uv.lock .
|
||||||
# Note on torch/CUDA: we do NOT install torch from a separate cu* index here.
|
# Note on torch/CUDA: we do NOT install torch from a separate cu* index here.
|
||||||
# PyPI's torch wheels for Linux x86_64 already ship CUDA-enabled and pull
|
# PyPI's torch wheels for Linux x86_64 already ship CUDA-enabled and pull
|
||||||
# nvidia-cudnn-cu13, nvidia-nccl-cu13, triton, etc. as install deps (all
|
# nvidia-cudnn-cu13, nvidia-nccl-cu13, triton, etc. as install deps (all
|
||||||
# captured in uv.lock). Installing from cu121 first only wasted ~2GB of
|
# captured in uv.lock). If a specific CUDA version is needed, wire it through
|
||||||
# downloads that the lock-based install immediately replaced. If a specific
|
|
||||||
# CUDA version is needed (driver compatibility, etc.), wire it through
|
|
||||||
# [tool.uv.sources] in pyproject.toml so the lock stays the source of truth.
|
# [tool.uv.sources] in pyproject.toml so the lock stays the source of truth.
|
||||||
RUN pip install --no-cache-dir uv && \
|
RUN pip install --no-cache-dir uv && \
|
||||||
uv export --frozen --no-dev --no-hashes --no-emit-project \
|
uv export --frozen --no-dev --no-hashes --no-emit-project \
|
||||||
|
|
@ -59,49 +72,42 @@ RUN pip install --no-cache-dir uv && \
|
||||||
uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \
|
uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \
|
||||||
rm /tmp/requirements.txt
|
rm /tmp/requirements.txt
|
||||||
|
|
||||||
# Set SSL environment variables dynamically
|
|
||||||
RUN CERTIFI_PATH=$(python -c "import certifi; print(certifi.where())") && \
|
# ─── Stage 3: models (pre-baked offline assets) ─────────────────────────────
|
||||||
echo "Setting SSL_CERT_FILE to $CERTIFI_PATH" && \
|
FROM deps AS models
|
||||||
echo "export SSL_CERT_FILE=$CERTIFI_PATH" >> /root/.bashrc && \
|
|
||||||
echo "export REQUESTS_CA_BUNDLE=$CERTIFI_PATH" >> /root/.bashrc
|
|
||||||
ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
|
||||||
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
|
||||||
|
|
||||||
# Pre-download EasyOCR models to avoid runtime SSL issues
|
# Pre-download EasyOCR models to avoid runtime SSL issues
|
||||||
RUN mkdir -p /root/.EasyOCR/model
|
RUN mkdir -p /root/.EasyOCR/model && \
|
||||||
RUN wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/v1.3/english_g2.zip -O /root/.EasyOCR/model/english_g2.zip || true
|
wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/v1.3/english_g2.zip -O /root/.EasyOCR/model/english_g2.zip || true && \
|
||||||
RUN wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/pre-v1.1.6/craft_mlt_25k.zip -O /root/.EasyOCR/model/craft_mlt_25k.zip || true
|
wget --no-check-certificate https://github.com/JaidedAI/EasyOCR/releases/download/pre-v1.1.6/craft_mlt_25k.zip -O /root/.EasyOCR/model/craft_mlt_25k.zip || true && \
|
||||||
RUN cd /root/.EasyOCR/model && (unzip -o english_g2.zip || true) && (unzip -o craft_mlt_25k.zip || true)
|
cd /root/.EasyOCR/model && \
|
||||||
|
(unzip -o english_g2.zip || true) && \
|
||||||
|
(unzip -o craft_mlt_25k.zip || true)
|
||||||
|
|
||||||
# Pre-download Docling models
|
# Pre-download Docling models
|
||||||
RUN python -c "try:\n from docling.document_converter import DocumentConverter\n conv = DocumentConverter()\nexcept:\n pass" || true
|
RUN printf '%s\n' \
|
||||||
|
'try:' \
|
||||||
|
' from docling.document_converter import DocumentConverter' \
|
||||||
|
' DocumentConverter()' \
|
||||||
|
'except Exception:' \
|
||||||
|
' pass' \
|
||||||
|
| python || true
|
||||||
|
|
||||||
# Install Playwright browsers for web scraping (the playwright package itself
|
ARG EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
# is already installed via uv.lock above)
|
RUN python -c "from chonkie import AutoEmbeddings; AutoEmbeddings.get_embeddings('${EMBEDDING_MODEL}')"
|
||||||
|
|
||||||
|
# Install Playwright browsers (the playwright python package itself is in deps)
|
||||||
RUN playwright install chromium --with-deps
|
RUN playwright install chromium --with-deps
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Install the project itself in editable mode. Dependencies were already
|
|
||||||
# installed deterministically from uv.lock above, so --no-deps prevents any
|
|
||||||
# re-resolution that could pull newer versions.
|
|
||||||
RUN uv pip install --system --no-cache-dir --no-deps -e .
|
|
||||||
|
|
||||||
# Copy and set permissions for entrypoint script
|
|
||||||
# Use dos2unix to ensure LF line endings (fixes CRLF issues from Windows checkouts)
|
|
||||||
COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
|
|
||||||
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
|
|
||||||
|
|
||||||
# Shared temp directory for file uploads between API and Worker containers.
|
# Shared temp directory for file uploads between API and Worker containers.
|
||||||
# Python's tempfile module uses TMPDIR, so uploaded files land here.
|
# Python's tempfile module uses TMPDIR, so uploaded files land here.
|
||||||
# Mount the SAME volume at /shared_tmp on both API and Worker in Coolify.
|
# Mount the SAME volume at /shared_tmp on both API and Worker in Coolify.
|
||||||
RUN mkdir -p /shared_tmp
|
RUN mkdir -p /shared_tmp
|
||||||
ENV TMPDIR=/shared_tmp
|
|
||||||
|
|
||||||
# Prevent uvloop compatibility issues
|
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
ENV UVICORN_LOOP=asyncio
|
ENV UVICORN_LOOP=asyncio
|
||||||
|
ENV TMPDIR=/shared_tmp
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Tune glibc malloc to return freed memory to the OS more aggressively.
|
# Tune glibc malloc to return freed memory to the OS more aggressively.
|
||||||
# Without these, Python's gc.collect() frees objects but the underlying
|
# Without these, Python's gc.collect() frees objects but the underlying
|
||||||
|
|
@ -110,6 +116,56 @@ ENV MALLOC_MMAP_THRESHOLD_=65536
|
||||||
ENV MALLOC_TRIM_THRESHOLD_=131072
|
ENV MALLOC_TRIM_THRESHOLD_=131072
|
||||||
ENV MALLOC_MMAP_MAX_=65536
|
ENV MALLOC_MMAP_MAX_=65536
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Stage 4: e2e (production source + tests/ + e2e entrypoint) ─────────────
|
||||||
|
# Built via `docker buildx build --target e2e`. The default build target is
|
||||||
|
# `production` (the last stage), so this stage is opt-in for CI only.
|
||||||
|
#
|
||||||
|
# `tests/` is excluded from the main build context by .dockerignore (so prod
|
||||||
|
# can never accidentally ship test fakes). The e2e stage receives tests/
|
||||||
|
# through an "additional context" passed by docker-compose.e2e.yml — see
|
||||||
|
# https://docs.docker.com/reference/compose-file/build/#additional_contexts
|
||||||
|
FROM models AS e2e
|
||||||
|
|
||||||
|
# Same source copy as production. .dockerignore filters out tests/.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Bring tests/ in via the named additional build context. CI passes
|
||||||
|
# --build-context tests-source=./tests
|
||||||
|
# (or the equivalent additional_contexts entry in docker-compose.e2e.yml).
|
||||||
|
COPY --from=tests-source . ./tests/
|
||||||
|
|
||||||
|
# Install the project itself in editable mode. Dependencies were already
|
||||||
|
# installed deterministically from uv.lock above, so --no-deps prevents any
|
||||||
|
# re-resolution that could pull newer versions.
|
||||||
|
RUN uv pip install --system --no-cache-dir --no-deps -e .
|
||||||
|
|
||||||
|
COPY scripts/docker/entrypoint.e2e.sh /app/scripts/docker/entrypoint.e2e.sh
|
||||||
|
RUN dos2unix /app/scripts/docker/entrypoint.e2e.sh && chmod +x /app/scripts/docker/entrypoint.e2e.sh
|
||||||
|
|
||||||
|
# SERVICE_ROLE is overridden per service in docker-compose.e2e.yml (api / worker).
|
||||||
|
ENV SERVICE_ROLE=api
|
||||||
|
|
||||||
|
EXPOSE 8000-8001
|
||||||
|
CMD ["/app/scripts/docker/entrypoint.e2e.sh"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Stage 5: production (published ghcr.io image) ──────────────────────────
|
||||||
|
# CI pins `target: production`; also the default for `docker build` / dev compose.
|
||||||
|
FROM models AS production
|
||||||
|
|
||||||
|
# Copy source code (tests/ excluded by .dockerignore — production never ships tests).
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install the project itself in editable mode. Dependencies were already
|
||||||
|
# installed deterministically from uv.lock above, so --no-deps prevents any
|
||||||
|
# re-resolution that could pull newer versions.
|
||||||
|
RUN uv pip install --system --no-cache-dir --no-deps -e .
|
||||||
|
|
||||||
|
# Use dos2unix to ensure LF line endings (fixes CRLF issues from Windows checkouts)
|
||||||
|
COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
|
||||||
|
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
|
||||||
|
|
||||||
# SERVICE_ROLE controls which process this container runs:
|
# SERVICE_ROLE controls which process this container runs:
|
||||||
# api – FastAPI backend only (runs migrations on startup)
|
# api – FastAPI backend only (runs migrations on startup)
|
||||||
# worker – Celery worker only
|
# worker – Celery worker only
|
||||||
|
|
@ -127,6 +183,5 @@ ENV CELERY_MAX_TASKS_PER_CHILD=50
|
||||||
# "" – both queues (default, for single-worker setups)
|
# "" – both queues (default, for single-worker setups)
|
||||||
ENV CELERY_QUEUES=""
|
ENV CELERY_QUEUES=""
|
||||||
|
|
||||||
# Run
|
|
||||||
EXPOSE 8000-8001
|
EXPOSE 8000-8001
|
||||||
CMD ["/app/scripts/docker/entrypoint.sh"]
|
CMD ["/app/scripts/docker/entrypoint.sh"]
|
||||||
|
|
@ -67,7 +67,11 @@ def run_migrations_offline() -> None:
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> None:
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
transaction_per_migration=True,
|
||||||
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if sa.inspect(bind).has_table("agent_action_log"):
|
||||||
|
return
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"agent_action_log",
|
"agent_action_log",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,21 @@ depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if inspector.has_table("document_revisions") and inspector.has_table(
|
||||||
|
"folder_revisions"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not inspector.has_table("document_revisions"):
|
||||||
|
_create_document_revisions()
|
||||||
|
if not inspector.has_table("folder_revisions"):
|
||||||
|
_create_folder_revisions()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_document_revisions() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"document_revisions",
|
"document_revisions",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
|
@ -74,6 +89,8 @@ def upgrade() -> None:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_folder_revisions() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"folder_revisions",
|
"folder_revisions",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if sa.inspect(bind).has_table("agent_permission_rules"):
|
||||||
|
return
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"agent_permission_rules",
|
"agent_permission_rules",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
|
|
||||||
|
|
@ -50,29 +50,39 @@ depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
bind = op.get_bind()
|
||||||
"agent_action_log",
|
inspector = sa.inspect(bind)
|
||||||
sa.Column("tool_call_id", sa.String(length=64), nullable=True),
|
columns = {c["name"] for c in inspector.get_columns("agent_action_log")}
|
||||||
)
|
indexes = {i["name"] for i in inspector.get_indexes("agent_action_log")}
|
||||||
op.add_column(
|
|
||||||
"agent_action_log",
|
|
||||||
sa.Column("chat_turn_id", sa.String(length=64), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.create_index(
|
if "tool_call_id" not in columns:
|
||||||
"ix_agent_action_log_tool_call_id",
|
op.add_column(
|
||||||
"agent_action_log",
|
"agent_action_log",
|
||||||
["tool_call_id"],
|
sa.Column("tool_call_id", sa.String(length=64), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index(
|
if "chat_turn_id" not in columns:
|
||||||
"ix_agent_action_log_chat_turn_id",
|
op.add_column(
|
||||||
"agent_action_log",
|
"agent_action_log",
|
||||||
["chat_turn_id"],
|
sa.Column("chat_turn_id", sa.String(length=64), nullable=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.execute(
|
if "ix_agent_action_log_tool_call_id" not in indexes:
|
||||||
"UPDATE agent_action_log SET tool_call_id = turn_id WHERE tool_call_id IS NULL"
|
op.create_index(
|
||||||
)
|
"ix_agent_action_log_tool_call_id",
|
||||||
|
"agent_action_log",
|
||||||
|
["tool_call_id"],
|
||||||
|
)
|
||||||
|
if "ix_agent_action_log_chat_turn_id" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_agent_action_log_chat_turn_id",
|
||||||
|
"agent_action_log",
|
||||||
|
["chat_turn_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "turn_id" in columns:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE agent_action_log SET tool_call_id = turn_id WHERE tool_call_id IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,22 @@ depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
bind = op.get_bind()
|
||||||
"new_chat_messages",
|
inspector = sa.inspect(bind)
|
||||||
sa.Column("turn_id", sa.String(length=64), nullable=True),
|
columns = {c["name"] for c in inspector.get_columns("new_chat_messages")}
|
||||||
)
|
indexes = {i["name"] for i in inspector.get_indexes("new_chat_messages")}
|
||||||
op.create_index(
|
|
||||||
"ix_new_chat_messages_turn_id",
|
if "turn_id" not in columns:
|
||||||
"new_chat_messages",
|
op.add_column(
|
||||||
["turn_id"],
|
"new_chat_messages",
|
||||||
)
|
sa.Column("turn_id", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
if "ix_new_chat_messages_turn_id" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_new_chat_messages_turn_id",
|
||||||
|
"new_chat_messages",
|
||||||
|
["turn_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "137"
|
revision: str = "137"
|
||||||
|
|
@ -39,6 +41,11 @@ _INDEX_NAME = "ux_agent_action_log_reverse_of"
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
indexes = {i["name"] for i in sa.inspect(bind).get_indexes("agent_action_log")}
|
||||||
|
if _INDEX_NAME in indexes:
|
||||||
|
return
|
||||||
|
|
||||||
# Defensively de-dup any pre-existing double-revert rows before
|
# Defensively de-dup any pre-existing double-revert rows before
|
||||||
# adding the unique index. Keeps the OLDEST row (smallest id) and
|
# adding the unique index. Keeps the OLDEST row (smallest id) and
|
||||||
# NULLs out the duplicates' ``reverse_of`` so they survive as audit
|
# NULLs out the duplicates' ``reverse_of`` so they survive as audit
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ TABLE_NAME = "new_chat_messages"
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
indexes = {i["name"] for i in sa.inspect(bind).get_indexes(TABLE_NAME)}
|
||||||
|
if INDEX_NAME in indexes:
|
||||||
|
return
|
||||||
|
|
||||||
op.create_index(
|
op.create_index(
|
||||||
INDEX_NAME,
|
INDEX_NAME,
|
||||||
TABLE_NAME,
|
TABLE_NAME,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
|
||||||
|
|
||||||
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
|
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
|
||||||
"deliverables": frozenset(),
|
"deliverables": frozenset(),
|
||||||
|
"knowledge_base": frozenset(),
|
||||||
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
|
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
|
||||||
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
|
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
|
||||||
"clickup": frozenset({"CLICKUP_CONNECTOR"}),
|
"clickup": frozenset({"CLICKUP_CONNECTOR"}),
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,9 @@ from langchain_core.language_models import BaseChatModel
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
from langgraph.types import Checkpointer
|
from langgraph.types import Checkpointer
|
||||||
|
|
||||||
from app.agents.multi_agent_chat.middleware import (
|
from app.agents.multi_agent_chat.middleware.stack import (
|
||||||
build_main_agent_deepagent_middleware,
|
build_main_agent_deepagent_middleware,
|
||||||
)
|
)
|
||||||
from app.agents.multi_agent_chat.subagents.shared.permissions import (
|
|
||||||
ToolsPermissions,
|
|
||||||
)
|
|
||||||
from app.agents.new_chat.context import SurfSenseContextSchema
|
from app.agents.new_chat.context import SurfSenseContextSchema
|
||||||
from app.agents.new_chat.feature_flags import AgentFeatureFlags
|
from app.agents.new_chat.feature_flags import AgentFeatureFlags
|
||||||
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
@ -42,7 +39,7 @@ def build_compiled_agent_graph_sync(
|
||||||
flags: AgentFeatureFlags,
|
flags: AgentFeatureFlags,
|
||||||
checkpointer: Checkpointer,
|
checkpointer: Checkpointer,
|
||||||
subagent_dependencies: dict[str, Any],
|
subagent_dependencies: dict[str, Any],
|
||||||
mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None,
|
mcp_tools_by_agent: dict[str, list[BaseTool]] | None = None,
|
||||||
disabled_tools: list[str] | None = None,
|
disabled_tools: list[str] | None = None,
|
||||||
):
|
):
|
||||||
"""Sync compile: middleware + ``create_agent`` (run via ``asyncio.to_thread``)."""
|
"""Sync compile: middleware + ``create_agent`` (run via ``asyncio.to_thread``)."""
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from langchain_core.language_models import BaseChatModel
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
from langgraph.types import Checkpointer
|
from langgraph.types import Checkpointer
|
||||||
|
|
||||||
from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions
|
|
||||||
from app.agents.new_chat.agent_cache import (
|
from app.agents.new_chat.agent_cache import (
|
||||||
flags_signature,
|
flags_signature,
|
||||||
get_cache,
|
get_cache,
|
||||||
|
|
@ -25,14 +24,14 @@ from app.db import ChatVisibility
|
||||||
from ..graph.compile_graph_sync import build_compiled_agent_graph_sync
|
from ..graph.compile_graph_sync import build_compiled_agent_graph_sync
|
||||||
|
|
||||||
|
|
||||||
def mcp_signature(mcp_tools_by_agent: dict[str, ToolsPermissions]) -> str:
|
def mcp_signature(mcp_tools_by_agent: dict[str, list[BaseTool]]) -> str:
|
||||||
"""Hash the per-agent MCP tool surface so a change rotates the cache key."""
|
"""Hash the per-agent MCP tool surface so a change rotates the cache key."""
|
||||||
rows = []
|
rows = []
|
||||||
for agent_name in sorted(mcp_tools_by_agent.keys()):
|
for agent_name in sorted(mcp_tools_by_agent.keys()):
|
||||||
perms = mcp_tools_by_agent[agent_name]
|
names = sorted(
|
||||||
allow_names = sorted(item.get("name", "") for item in perms.get("allow", []))
|
getattr(t, "name", "") or "" for t in mcp_tools_by_agent[agent_name]
|
||||||
ask_names = sorted(item.get("name", "") for item in perms.get("ask", []))
|
)
|
||||||
rows.append((agent_name, allow_names, ask_names))
|
rows.append((agent_name, names))
|
||||||
return stable_hash(rows)
|
return stable_hash(rows)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,7 +54,7 @@ async def build_agent_with_cache(
|
||||||
flags: AgentFeatureFlags,
|
flags: AgentFeatureFlags,
|
||||||
checkpointer: Checkpointer,
|
checkpointer: Checkpointer,
|
||||||
subagent_dependencies: dict[str, Any],
|
subagent_dependencies: dict[str, Any],
|
||||||
mcp_tools_by_agent: dict[str, ToolsPermissions],
|
mcp_tools_by_agent: dict[str, list[BaseTool]],
|
||||||
disabled_tools: list[str] | None,
|
disabled_tools: list[str] | None,
|
||||||
config_id: str | None,
|
config_id: str | None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import time
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deepagents.graph import BASE_AGENT_PROMPT
|
|
||||||
from langchain_core.language_models import BaseChatModel
|
from langchain_core.language_models import BaseChatModel
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
from langgraph.types import Checkpointer
|
from langgraph.types import Checkpointer
|
||||||
|
|
@ -30,6 +29,10 @@ from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_to
|
||||||
from app.agents.new_chat.tools.registry import build_tools_async
|
from app.agents.new_chat.tools.registry import build_tools_async
|
||||||
from app.db import ChatVisibility
|
from app.db import ChatVisibility
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
|
from app.services.user_tool_allowlist import (
|
||||||
|
fetch_user_allowlist_rulesets,
|
||||||
|
make_trusted_tool_saver,
|
||||||
|
)
|
||||||
from app.utils.perf import get_perf_logger
|
from app.utils.perf import get_perf_logger
|
||||||
|
|
||||||
from ..system_prompt import build_main_agent_system_prompt
|
from ..system_prompt import build_main_agent_system_prompt
|
||||||
|
|
@ -142,11 +145,49 @@ async def create_multi_agent_chat_deep_agent(
|
||||||
)
|
)
|
||||||
mcp_tools_by_agent = {}
|
mcp_tools_by_agent = {}
|
||||||
_perf_log.info(
|
_perf_log.info(
|
||||||
"[create_agent] load_mcp_tools_by_connector in %.3fs (%d buckets)",
|
"[create_agent] load_mcp_tools_by_connector in %.3fs (%d agents)",
|
||||||
time.perf_counter() - _t0,
|
time.perf_counter() - _t0,
|
||||||
len(mcp_tools_by_agent),
|
len(mcp_tools_by_agent),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User-scoped allow-list ("Always Allow" persisted to
|
||||||
|
# ``SearchSourceConnector.config.trusted_tools``). Layered last in each
|
||||||
|
# subagent's PermissionMiddleware so user ``allow`` overrides coded
|
||||||
|
# ``ask`` via last-match-wins. Anonymous turns and read failures both
|
||||||
|
# degrade to "no user rules" rather than blocking the turn.
|
||||||
|
user_allowlist_by_subagent: dict[str, Any] = {}
|
||||||
|
trusted_tool_saver = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
user_uuid = _uuid.UUID(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
user_uuid = None
|
||||||
|
|
||||||
|
if user_uuid is not None:
|
||||||
|
_t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
user_allowlist_by_subagent = await fetch_user_allowlist_rulesets(
|
||||||
|
db_session,
|
||||||
|
user_id=user_uuid,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(
|
||||||
|
"User allow-list fetch failed; subagents will run without user trust rules this turn: %s",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
user_allowlist_by_subagent = {}
|
||||||
|
_perf_log.info(
|
||||||
|
"[create_agent] fetch_user_allowlist_rulesets in %.3fs (%d subagents have rules)",
|
||||||
|
time.perf_counter() - _t0,
|
||||||
|
len(user_allowlist_by_subagent),
|
||||||
|
)
|
||||||
|
trusted_tool_saver = make_trusted_tool_saver(user_uuid)
|
||||||
|
dependencies["user_allowlist_by_subagent"] = user_allowlist_by_subagent
|
||||||
|
dependencies["trusted_tool_saver"] = trusted_tool_saver
|
||||||
|
|
||||||
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
|
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
|
||||||
|
|
||||||
if "search_knowledge_base" not in modified_disabled_tools:
|
if "search_knowledge_base" not in modified_disabled_tools:
|
||||||
|
|
@ -218,7 +259,7 @@ async def create_multi_agent_chat_deep_agent(
|
||||||
"[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0
|
"[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0
|
||||||
)
|
)
|
||||||
|
|
||||||
final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT
|
final_system_prompt = system_prompt
|
||||||
|
|
||||||
config_id = agent_config.config_id if agent_config is not None else None
|
config_id = agent_config.config_id if agent_config is not None else None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Assemble the main-agent system prompt from ``markdown/*.md`` fragments."""
|
"""Assemble the main-agent system prompt from ``prompts/`` fragments."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
"""Assemble the **main-agent** deep-agent system string only.
|
"""Assemble the main-agent system prompt from ``prompts/``.
|
||||||
|
|
||||||
Sections (order matters): core instructions → provider → citations → dynamic
|
Section order (default flow)::
|
||||||
``<registry_subagents>`` → SurfSense ``<tools>``.
|
|
||||||
|
<agent_identity>
|
||||||
|
[user's custom_system_instructions, if any]
|
||||||
|
<core_behavior> # default body
|
||||||
|
<knowledge_base_first> # default body
|
||||||
|
<dynamic_context> # always
|
||||||
|
<routing> # default body
|
||||||
|
<specialists> # always (dynamic roster)
|
||||||
|
<tools> # always (vertical-slice)
|
||||||
|
<memory_protocol> # default body
|
||||||
|
<citations> # always
|
||||||
|
<output_format> # always
|
||||||
|
<refusal_and_limits> # always
|
||||||
|
<reminder> # always
|
||||||
|
|
||||||
|
``custom_system_instructions`` is **additive**, not a replacement: it slots
|
||||||
|
between identity and the default body so platform safety nets (KB-first,
|
||||||
|
routing, citations, output formatting, refusal rules) always apply.
|
||||||
|
|
||||||
|
``use_default_system_instructions=False`` skips the four "default body"
|
||||||
|
sections but keeps all the always-on platform sections.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -10,15 +30,18 @@ from datetime import UTC, datetime
|
||||||
|
|
||||||
from app.db import ChatVisibility
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
|
from .load_md import read_prompt_md
|
||||||
from .sections.citations import build_citations_section
|
from .sections.citations import build_citations_section
|
||||||
from .sections.provider import build_provider_section
|
from .sections.dynamic_context import build_dynamic_context_section
|
||||||
from .sections.registry_subagents import build_registry_subagents_section
|
from .sections.identity import build_identity_section
|
||||||
from .sections.system_instruction import build_default_system_instruction_xml
|
from .sections.memory_protocol import build_memory_protocol_section
|
||||||
|
from .sections.specialists import build_specialists_section
|
||||||
from .sections.tools import build_tools_section
|
from .sections.tools import build_tools_section
|
||||||
|
|
||||||
|
|
||||||
def build_main_agent_system_prompt(
|
def build_main_agent_system_prompt(
|
||||||
*,
|
*,
|
||||||
|
registry_subagent_prompt_lines: list[tuple[str, str]],
|
||||||
today: datetime | None = None,
|
today: datetime | None = None,
|
||||||
thread_visibility: ChatVisibility | None = None,
|
thread_visibility: ChatVisibility | None = None,
|
||||||
enabled_tool_names: set[str] | None = None,
|
enabled_tool_names: set[str] | None = None,
|
||||||
|
|
@ -27,27 +50,51 @@ def build_main_agent_system_prompt(
|
||||||
use_default_system_instructions: bool = True,
|
use_default_system_instructions: bool = True,
|
||||||
citations_enabled: bool = True,
|
citations_enabled: bool = True,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
registry_subagent_prompt_lines: list[tuple[str, str]] | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
|
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
|
||||||
visibility = thread_visibility or ChatVisibility.PRIVATE
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
|
|
||||||
if custom_system_instructions and custom_system_instructions.strip():
|
parts: list[str] = []
|
||||||
system_block = custom_system_instructions.format(resolved_today=resolved_today)
|
|
||||||
elif use_default_system_instructions:
|
|
||||||
system_block = build_default_system_instruction_xml(
|
|
||||||
visibility=visibility,
|
|
||||||
resolved_today=resolved_today,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
system_block = ""
|
|
||||||
|
|
||||||
system_block += build_provider_section(model_name=model_name)
|
parts.append(
|
||||||
system_block += build_citations_section(citations_enabled=citations_enabled)
|
build_identity_section(visibility=visibility, resolved_today=resolved_today)
|
||||||
system_block += build_registry_subagents_section(registry_subagent_prompt_lines)
|
|
||||||
system_block += build_tools_section(
|
|
||||||
visibility=visibility,
|
|
||||||
enabled_tool_names=enabled_tool_names,
|
|
||||||
disabled_tool_names=disabled_tool_names,
|
|
||||||
)
|
)
|
||||||
return system_block
|
|
||||||
|
if custom_system_instructions and custom_system_instructions.strip():
|
||||||
|
parts.append(
|
||||||
|
"\n"
|
||||||
|
+ custom_system_instructions.format(resolved_today=resolved_today)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_default_system_instructions:
|
||||||
|
parts.append(_wrap(read_prompt_md("core_behavior.md")))
|
||||||
|
parts.append(_wrap(read_prompt_md("kb_first.md")))
|
||||||
|
|
||||||
|
parts.append(build_dynamic_context_section(visibility=visibility))
|
||||||
|
|
||||||
|
if use_default_system_instructions:
|
||||||
|
parts.append(_wrap(read_prompt_md("routing.md")))
|
||||||
|
|
||||||
|
parts.append(build_specialists_section(registry_subagent_prompt_lines))
|
||||||
|
parts.append(
|
||||||
|
build_tools_section(
|
||||||
|
visibility=visibility,
|
||||||
|
enabled_tool_names=enabled_tool_names,
|
||||||
|
disabled_tool_names=disabled_tool_names,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_default_system_instructions:
|
||||||
|
parts.append(build_memory_protocol_section(visibility=visibility))
|
||||||
|
|
||||||
|
parts.append(build_citations_section(citations_enabled=citations_enabled))
|
||||||
|
parts.append(_wrap(read_prompt_md("output_format.md")))
|
||||||
|
parts.append(_wrap(read_prompt_md("refusal_and_limits.md")))
|
||||||
|
parts.append(_wrap(read_prompt_md("reminder.md")))
|
||||||
|
|
||||||
|
return "".join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(fragment: str) -> str:
|
||||||
|
return f"\n{fragment}\n" if fragment else ""
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""Load main-agent-only markdown from ``system_prompt/markdown/`` (``importlib.resources``)."""
|
"""Load main-agent prompt fragments from ``system_prompt/prompts/``."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
|
|
||||||
_PROMPTS_PACKAGE = "app.agents.multi_agent_chat.main_agent.system_prompt.markdown"
|
_PROMPTS_PACKAGE = "app.agents.multi_agent_chat.main_agent.system_prompt.prompts"
|
||||||
|
|
||||||
|
|
||||||
def read_prompt_md(filename: str) -> str:
|
def read_prompt_md(filename: str) -> str:
|
||||||
"""Load ``markdown/{filename}`` (e.g. ``agent_private.md`` or ``tools/_preamble.md``)."""
|
"""Load ``prompts/{filename}`` (e.g. ``core_behavior.md`` or ``tools/web_search/description.md``)."""
|
||||||
ref = resources.files(_PROMPTS_PACKAGE).joinpath(filename)
|
ref = resources.files(_PROMPTS_PACKAGE).joinpath(filename)
|
||||||
if not ref.is_file():
|
if not ref.is_file():
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Provider-specific style hints from ``markdown/providers/`` (main agent only)."""
|
"""Provider-specific style hints from ``prompts/providers/`` (main agent only)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Citation fragment for the main agent (chunk-tagged context only)."""
|
"""``<citations>`` section — on/off variant based on workspace configuration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,6 +6,6 @@ from ..load_md import read_prompt_md
|
||||||
|
|
||||||
|
|
||||||
def build_citations_section(*, citations_enabled: bool) -> str:
|
def build_citations_section(*, citations_enabled: bool) -> str:
|
||||||
name = "citations_on.md" if citations_enabled else "citations_off.md"
|
variant = "on" if citations_enabled else "off"
|
||||||
fragment = read_prompt_md(name)
|
fragment = read_prompt_md(f"citations/{variant}.md")
|
||||||
return f"\n{fragment}\n" if fragment else ""
|
return f"\n{fragment}\n" if fragment else ""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""``<dynamic_context>`` section — visibility-aware (private vs team thread)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
|
from ..load_md import read_prompt_md
|
||||||
|
|
||||||
|
|
||||||
|
def build_dynamic_context_section(*, visibility: ChatVisibility) -> str:
|
||||||
|
variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
|
||||||
|
fragment = read_prompt_md(f"dynamic_context/{variant}.md")
|
||||||
|
return f"\n{fragment}\n" if fragment else ""
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""``<agent_identity>`` section — visibility-aware, with ``{resolved_today}`` injection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
|
from ..load_md import read_prompt_md
|
||||||
|
|
||||||
|
|
||||||
|
def build_identity_section(
|
||||||
|
*,
|
||||||
|
visibility: ChatVisibility,
|
||||||
|
resolved_today: str,
|
||||||
|
) -> str:
|
||||||
|
variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
|
||||||
|
fragment = read_prompt_md(f"identity/{variant}.md")
|
||||||
|
if not fragment:
|
||||||
|
return ""
|
||||||
|
return "\n" + fragment.format(resolved_today=resolved_today) + "\n"
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""``<memory_protocol>`` section — visibility-aware (user vs team memory)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
|
from ..load_md import read_prompt_md
|
||||||
|
|
||||||
|
|
||||||
|
def build_memory_protocol_section(*, visibility: ChatVisibility) -> str:
|
||||||
|
variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
|
||||||
|
fragment = read_prompt_md(f"memory_protocol/{variant}.md")
|
||||||
|
return f"\n{fragment}\n" if fragment else ""
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Dynamic ``<registry_subagents>`` block: **task** specialists actually built for this workspace."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def build_registry_subagents_section(
|
|
||||||
registry_subagent_lines: list[tuple[str, str]] | None,
|
|
||||||
) -> str:
|
|
||||||
if registry_subagent_lines is None:
|
|
||||||
return ""
|
|
||||||
if not registry_subagent_lines:
|
|
||||||
return (
|
|
||||||
"\n<registry_subagents>\n"
|
|
||||||
"No registry specialists are listed for **task** in this workspace.\n"
|
|
||||||
"</registry_subagents>\n"
|
|
||||||
)
|
|
||||||
bullets = "\n".join(
|
|
||||||
f"- **{name}** — {desc}" for name, desc in registry_subagent_lines
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
"\n<registry_subagents>\n"
|
|
||||||
"These specialists are registered for **task** (routes without a matching connector are omitted).\n"
|
|
||||||
f"{bullets}\n"
|
|
||||||
"The runtime may also offer a general-purpose **task** helper with your tools in a separate context.\n"
|
|
||||||
"Pick the specialist by **name**. Put full instructions in the task prompt; they do not see this thread.\n"
|
|
||||||
"</registry_subagents>\n"
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""``<specialists>`` section — live ``task`` roster for this workspace.
|
||||||
|
|
||||||
|
The roster is non-empty by contract: ``deliverables`` and ``knowledge_base``
|
||||||
|
both declare ``frozenset()`` in ``SUBAGENT_TO_REQUIRED_CONNECTOR_MAP``, so
|
||||||
|
they survive every connector-based exclusion pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def build_specialists_section(
|
||||||
|
specialist_lines: list[tuple[str, str]],
|
||||||
|
) -> str:
|
||||||
|
bullets = "\n".join(f"- **{name}** — {desc}" for name, desc in specialist_lines)
|
||||||
|
return f"\n<specialists>\n{bullets}\n</specialists>\n"
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
"""Default ``<system_instruction>`` block for the main agent only."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.db import ChatVisibility
|
|
||||||
|
|
||||||
from ..load_md import read_prompt_md
|
|
||||||
|
|
||||||
_PRIVATE_ORDER = (
|
|
||||||
"agent_private.md",
|
|
||||||
"kb_only_policy_private.md",
|
|
||||||
"main_agent_tool_routing.md",
|
|
||||||
"parameter_resolution.md",
|
|
||||||
"memory_protocol_private.md",
|
|
||||||
)
|
|
||||||
_TEAM_ORDER = (
|
|
||||||
"agent_team.md",
|
|
||||||
"kb_only_policy_team.md",
|
|
||||||
"main_agent_tool_routing.md",
|
|
||||||
"parameter_resolution.md",
|
|
||||||
"memory_protocol_team.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_default_system_instruction_xml(
|
|
||||||
*,
|
|
||||||
visibility: ChatVisibility,
|
|
||||||
resolved_today: str,
|
|
||||||
) -> str:
|
|
||||||
order = _TEAM_ORDER if visibility == ChatVisibility.SEARCH_SPACE else _PRIVATE_ORDER
|
|
||||||
parts = [read_prompt_md(name) for name in order]
|
|
||||||
body = "\n\n".join(p for p in parts if p)
|
|
||||||
return f"\n<system_instruction>\n{body}\n\n</system_instruction>\n".format(
|
|
||||||
resolved_today=resolved_today,
|
|
||||||
)
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Main-agent ``<tools>`` block (memory + research builtins only; see ``main_agent.tools``)."""
|
"""Main-agent ``<tools>`` block (memory + research builtins + ``task``)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""``<tools>`` + ``<tool_call_examples>`` from ``system_prompt/markdown/{tools,examples}/``.
|
"""Compose the ``<tools>`` block from per-tool vertical-slice folders.
|
||||||
|
|
||||||
Only documents tools the main agent actually binds — not full ``new_chat``.
|
Each tool lives in ``prompts/tools/<name>/`` with ``description.md`` and an
|
||||||
|
``example.md``. Visibility variants live in ``{private,team}/`` subfolders.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -13,16 +14,10 @@ from .load_md import read_prompt_md
|
||||||
_MEMORY_VARIANT_TOOLS: frozenset[str] = frozenset({"update_memory"})
|
_MEMORY_VARIANT_TOOLS: frozenset[str] = frozenset({"update_memory"})
|
||||||
|
|
||||||
|
|
||||||
def _tool_fragment_path(tool_name: str, variant: str) -> str:
|
def _tool_fragment(tool_name: str, variant: str, leaf: str) -> str:
|
||||||
if tool_name in _MEMORY_VARIANT_TOOLS:
|
if tool_name in _MEMORY_VARIANT_TOOLS:
|
||||||
return f"tools/{tool_name}_{variant}.md"
|
return read_prompt_md(f"tools/{tool_name}/{variant}/{leaf}")
|
||||||
return f"tools/{tool_name}.md"
|
return read_prompt_md(f"tools/{tool_name}/{leaf}")
|
||||||
|
|
||||||
|
|
||||||
def _example_fragment_path(tool_name: str, variant: str) -> str:
|
|
||||||
if tool_name in _MEMORY_VARIANT_TOOLS:
|
|
||||||
return f"examples/{tool_name}_{variant}.md"
|
|
||||||
return f"examples/{tool_name}.md"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_label(tool_name: str) -> str:
|
def _format_tool_label(tool_name: str) -> str:
|
||||||
|
|
@ -35,26 +30,35 @@ def build_tools_instruction_block(
|
||||||
enabled_tool_names: set[str] | None,
|
enabled_tool_names: set[str] | None,
|
||||||
disabled_tool_names: set[str] | None,
|
disabled_tool_names: set[str] | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Render ``<tools>``. ``task`` is always included: at least ``deliverables``
|
||||||
|
and ``knowledge_base`` are always in ``<specialists>`` (see constants)."""
|
||||||
variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
|
variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private"
|
||||||
|
|
||||||
parts: list[str] = []
|
parts: list[str] = ["\n<tools>\n"]
|
||||||
preamble = read_prompt_md("tools/_preamble.md")
|
|
||||||
if preamble:
|
|
||||||
parts.append(preamble + "\n")
|
|
||||||
|
|
||||||
examples: list[str] = []
|
|
||||||
|
|
||||||
for tool_name in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED:
|
for tool_name in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED:
|
||||||
if enabled_tool_names is not None and tool_name not in enabled_tool_names:
|
if enabled_tool_names is not None and tool_name not in enabled_tool_names:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
instruction = read_prompt_md(_tool_fragment_path(tool_name, variant))
|
description = _tool_fragment(tool_name, variant, "description.md")
|
||||||
if instruction:
|
example = _tool_fragment(tool_name, variant, "example.md")
|
||||||
parts.append(instruction + "\n")
|
|
||||||
|
|
||||||
example = read_prompt_md(_example_fragment_path(tool_name, variant))
|
if not description and not example:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if description:
|
||||||
|
parts.append(description + "\n")
|
||||||
if example:
|
if example:
|
||||||
examples.append(example + "\n")
|
parts.append("\n" + example + "\n")
|
||||||
|
parts.append("\n")
|
||||||
|
|
||||||
|
task_description = read_prompt_md("tools/task/description.md")
|
||||||
|
task_example = read_prompt_md("tools/task/example.md")
|
||||||
|
if task_description:
|
||||||
|
parts.append(task_description + "\n")
|
||||||
|
if task_example:
|
||||||
|
parts.append("\n" + task_example + "\n")
|
||||||
|
parts.append("\n")
|
||||||
|
|
||||||
known_disabled = (
|
known_disabled = (
|
||||||
set(disabled_tool_names) & set(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED)
|
set(disabled_tool_names) & set(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED)
|
||||||
|
|
@ -68,19 +72,13 @@ def build_tools_instruction_block(
|
||||||
if n in known_disabled
|
if n in known_disabled
|
||||||
)
|
)
|
||||||
parts.append(
|
parts.append(
|
||||||
"\n"
|
"<disabled_tools>\n"
|
||||||
"DISABLED TOOLS (by user, main-agent scope):\n"
|
f"Disabled for this session: {disabled_list}.\n"
|
||||||
f"These SurfSense tools were disabled on the main agent for this session: {disabled_list}.\n"
|
"Don't claim you can use them. If the user needs that capability,\n"
|
||||||
"You do NOT have access to them and MUST NOT claim you can use them.\n"
|
"delegate with `task` when a specialist covers it; otherwise say\n"
|
||||||
"If the user still needs that capability, delegate with **task** if a subagent covers it,\n"
|
"the tool is disabled.\n"
|
||||||
"otherwise explain it is disabled on the main agent for this session.\n"
|
"</disabled_tools>\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
parts.append("\n</tools>\n")
|
parts.append("</tools>\n")
|
||||||
|
|
||||||
if examples:
|
|
||||||
parts.append("<tool_call_examples>")
|
|
||||||
parts.extend(examples)
|
|
||||||
parts.append("</tool_call_examples>\n")
|
|
||||||
|
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Markdown fragments for the **main-agent** system prompt only (`importlib.resources`)."""
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
You are SurfSense’s **main agent**: you answer using the user’s knowledge context,
|
|
||||||
lightweight research tools, and memory — and you **delegate** integrations and
|
|
||||||
specialized work via **task** (see `<tool_routing>` in this prompt).
|
|
||||||
|
|
||||||
Today's date (UTC): {resolved_today}
|
|
||||||
|
|
||||||
When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math.
|
|
||||||
|
|
||||||
NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead.
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
You are SurfSense’s **main agent** for this team space: you answer using shared
|
|
||||||
knowledge context, lightweight research tools, and memory — and you **delegate**
|
|
||||||
integrations and specialized work via **task** (see `<tool_routing>` in this prompt).
|
|
||||||
|
|
||||||
In this team thread, each message is prefixed with **[DisplayName of the author]**. Use this to attribute and reference the author of anything in the discussion (who asked a question, made a suggestion, or contributed an idea) and to cite who said what in your answers.
|
|
||||||
|
|
||||||
Today's date (UTC): {resolved_today}
|
|
||||||
|
|
||||||
When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math.
|
|
||||||
|
|
||||||
NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<citation_instructions>
|
|
||||||
IMPORTANT: Citations are DISABLED for this configuration.
|
|
||||||
|
|
||||||
DO NOT include `[citation:…]` markers anywhere — even if tool descriptions or examples
|
|
||||||
mention them. Ignore citation-format reminders elsewhere in this prompt when they conflict
|
|
||||||
with this block.
|
|
||||||
|
|
||||||
Instead:
|
|
||||||
1. Answer in plain prose; optional markdown links to public URLs when sources are URLs.
|
|
||||||
2. Do NOT expose raw chunk IDs, document IDs, or internal IDs to the user.
|
|
||||||
3. Present indexed or doc-search facts naturally without attribution markers.
|
|
||||||
|
|
||||||
When answering from workspace or docs context: integrate facts cleanly without claiming
|
|
||||||
“this comes from chunk X”.
|
|
||||||
</citation_instructions>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<citation_instructions>
|
|
||||||
This block appears **before** `<tools>` so it wins over any tool-example wording below.
|
|
||||||
|
|
||||||
Apply chunk citations **only** when the runtime injects `<document>` / `<chunk id='…'>` blocks
|
|
||||||
(e.g. from SurfSense docs search or priority documents).
|
|
||||||
|
|
||||||
1. For each factual statement taken from those chunks, add `[citation:chunk_id]` using the **exact** `chunk_id` string from `<chunk id='…'>`.
|
|
||||||
2. Multiple chunks → `[citation:id1], [citation:id2]` (comma-separated).
|
|
||||||
3. Never invent or normalize ids; if unsure, omit the citation.
|
|
||||||
4. Plain brackets only — no markdown links, no `([citation:…](url))`, no footnote numbering.
|
|
||||||
|
|
||||||
Chunk ids may be numeric, prefixed (e.g. `doc-45`), or URLs when the source is web-shaped — copy verbatim.
|
|
||||||
|
|
||||||
If no chunk-tagged documents appear in context this turn, do not fabricate citations.
|
|
||||||
</citation_instructions>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
|
|
||||||
- User: "Check out https://dev.to/some-article"
|
|
||||||
- Call: `scrape_webpage(url="https://dev.to/some-article")`
|
|
||||||
- Respond with a structured analysis — key points, takeaways.
|
|
||||||
- User: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
|
|
||||||
- Call: `scrape_webpage(url="https://example.com/blog/ai-trends")`
|
|
||||||
- Respond with a thorough summary using headings and bullet points.
|
|
||||||
- User: (after discussing https://example.com/stats) "Can you get the live data from that page?"
|
|
||||||
- Call: `scrape_webpage(url="https://example.com/stats")`
|
|
||||||
- IMPORTANT: Always attempt scraping first. Never refuse before trying the tool.
|
|
||||||
- User: "https://example.com/blog/weekend-recipes"
|
|
||||||
- Call: `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 of the content.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
|
|
||||||
- User: "How do I install SurfSense?"
|
|
||||||
- Call: `search_surfsense_docs(query="installation setup")`
|
|
||||||
- User: "What connectors does SurfSense support?"
|
|
||||||
- Call: `search_surfsense_docs(query="available connectors integrations")`
|
|
||||||
- User: "How do I set up the Notion connector?"
|
|
||||||
- Call: `search_surfsense_docs(query="Notion connector setup configuration")` (how-to docs). Changing data inside Notion itself → **task**.
|
|
||||||
- User: "How do I use Docker to run SurfSense?"
|
|
||||||
- Call: `search_surfsense_docs(query="Docker installation setup")`
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
|
|
||||||
- <user_name>Alex</user_name>, <user_memory> is empty. User: "I'm a space enthusiast, explain astrophage to me"
|
|
||||||
- The user casually shared a durable fact. Use their first name in the entry, short neutral heading:
|
|
||||||
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
|
|
||||||
- User: "Remember that I prefer concise answers over detailed explanations"
|
|
||||||
- Durable preference. Merge with existing memory, add a new heading:
|
|
||||||
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
|
|
||||||
- User: "I actually moved to Tokyo last month"
|
|
||||||
- Updated fact, date prefix reflects when recorded:
|
|
||||||
update_memory(updated_memory="## Interests & background\n...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
|
|
||||||
- User: "I'm a freelance photographer working on a nature documentary"
|
|
||||||
- Durable background info under a fitting heading:
|
|
||||||
update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
|
|
||||||
- User: "Always respond in bullet points"
|
|
||||||
- Standing instruction:
|
|
||||||
update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
|
|
||||||
- User: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
|
||||||
- Durable team decision:
|
|
||||||
update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
|
|
||||||
- User: "Our office is in downtown Seattle, 5th floor"
|
|
||||||
- Durable team fact:
|
|
||||||
update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
|
|
||||||
- User: "What's the current USD to INR exchange rate?"
|
|
||||||
- Call: `web_search(query="current USD to INR exchange rate")`
|
|
||||||
- Answer from returned snippets or scrape a top URL if needed; use markdown links to sources.
|
|
||||||
- User: "What's the latest news about AI?"
|
|
||||||
- Call: `web_search(query="latest AI news today")`
|
|
||||||
- User: "What's the weather in New York?"
|
|
||||||
- Call: `web_search(query="weather New York today")`
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<knowledge_base_only_policy>
|
|
||||||
CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE:
|
|
||||||
- Ground factual answers in what you actually receive this turn: injected workspace
|
|
||||||
documents (when present), **search_surfsense_docs**, **web_search**, **scrape_webpage**,
|
|
||||||
or substantive results summarized from a **task** subagent you invoked.
|
|
||||||
- Do NOT answer factual or informational questions from general knowledge unless the user
|
|
||||||
explicitly grants permission after you say you did not find enough in those sources.
|
|
||||||
- If indexed/docs search returns nothing relevant AND **web_search** / **scrape_webpage**
|
|
||||||
(and **task**, if already tried appropriately) still do not supply an answer, you MUST:
|
|
||||||
1. Say you could not find enough in their workspace/docs/tools output.
|
|
||||||
2. Ask: "Would you like me to answer from my general knowledge instead?"
|
|
||||||
3. ONLY then answer from general knowledge after they clearly say yes.
|
|
||||||
- This policy does NOT apply to:
|
|
||||||
* Casual conversation, greetings, or meta-questions about SurfSense (e.g. "what can you do?")
|
|
||||||
* Formatting or analysis of content already in the chat
|
|
||||||
* Clear rewrite/edit instructions ("bullet-point this paragraph")
|
|
||||||
* Lightweight research with **web_search** / **scrape_webpage**
|
|
||||||
* Work that belongs on a specialist — use **task**; see `<tool_routing>`
|
|
||||||
</knowledge_base_only_policy>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<knowledge_base_only_policy>
|
|
||||||
CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE:
|
|
||||||
- Ground factual answers in what you actually receive this turn: injected shared
|
|
||||||
workspace documents (when present), **search_surfsense_docs**, **web_search**,
|
|
||||||
**scrape_webpage**, or substantive results summarized from a **task** subagent you invoked.
|
|
||||||
- Do NOT answer factual questions from general knowledge unless a team member explicitly
|
|
||||||
grants permission after you say you did not find enough in those sources.
|
|
||||||
- If indexed/docs search returns nothing relevant AND **web_search** / **scrape_webpage**
|
|
||||||
(and **task**, if already tried appropriately) still do not supply an answer, you MUST:
|
|
||||||
1. Say you could not find enough in shared docs/tools output.
|
|
||||||
2. Ask: "Would you like me to answer from my general knowledge instead?"
|
|
||||||
3. ONLY then answer from general knowledge after they clearly say yes.
|
|
||||||
- This policy does NOT apply to:
|
|
||||||
* Casual conversation, greetings, or meta-questions about SurfSense
|
|
||||||
* Formatting or analysis of content already in the chat
|
|
||||||
* Clear rewrite/edit instructions
|
|
||||||
* Lightweight research with **web_search** / **scrape_webpage**
|
|
||||||
* Work that belongs on a specialist — use **task**; see `<tool_routing>`
|
|
||||||
</knowledge_base_only_policy>
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<tool_routing>
|
|
||||||
Use **task** for anything beyond your direct SurfSense tools: calendar, mail,
|
|
||||||
chat, tickets, documents in third-party systems, connector-specific discovery,
|
|
||||||
deliverables (reports, podcasts, images, etc.), and other specialized routes.
|
|
||||||
The live list of specialists you may target with **task** for this workspace is in
|
|
||||||
`<registry_subagents>` (later in this prompt).
|
|
||||||
|
|
||||||
Your **direct** SurfSense tools are only: **update_memory**, **web_search**,
|
|
||||||
**scrape_webpage**, and **search_surfsense_docs**. The runtime may also attach
|
|
||||||
deep-agent helpers (e.g. todos, filesystem, **task** itself). Use **task** whenever
|
|
||||||
the user needs capabilities **not** listed in the `<tools>` section (that section appears
|
|
||||||
later in this system prompt, after citation rules).
|
|
||||||
|
|
||||||
Do not treat live third-party state as if it were already in the indexed knowledge
|
|
||||||
base; reach it via **task**.
|
|
||||||
|
|
||||||
Never emit more than one **task** tool call in the same turn. Bundle related work
|
|
||||||
for the same specialist into a single **task** invocation (the subagent itself can
|
|
||||||
call its own tools in parallel inside that one run). Parallel **task** calls would
|
|
||||||
fan out into multiple concurrent subagent runs whose human-approval interrupts
|
|
||||||
cannot be coordinated; one **task** at a time is required.
|
|
||||||
</tool_routing>
|
|
||||||
|
|
||||||
<!-- TODO: lift the single-task constraint once the runtime supports parallel task
|
|
||||||
interrupts end-to-end (multi-interrupt SSE + interrupt-id-keyed Command(resume)
|
|
||||||
+ keyed surfsense_resume_value side-channel). Until then this nudge is the only
|
|
||||||
guard; the parent graph's resume cannot address multiple pending interrupts. -->
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<memory_protocol>
|
|
||||||
IMPORTANT — After understanding each user message, ALWAYS check: does this message
|
|
||||||
reveal durable facts about the user (role, interests, preferences, projects,
|
|
||||||
background, or standing instructions)? If yes, you MUST call update_memory
|
|
||||||
alongside your normal response — do not defer this to a later turn.
|
|
||||||
</memory_protocol>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<memory_protocol>
|
|
||||||
IMPORTANT — After understanding each user message, ALWAYS check: does this message
|
|
||||||
reveal durable facts about the team (decisions, conventions, architecture, processes,
|
|
||||||
or key facts)? If yes, you MUST call update_memory alongside your normal response —
|
|
||||||
do not defer this to a later turn.
|
|
||||||
</memory_protocol>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<parameter_resolution>
|
|
||||||
You do **not** call connector-specific discovery tools yourself (accounts, channels,
|
|
||||||
Jira cloud IDs, Airtable bases, Slack channels, etc.). Those tools exist only on
|
|
||||||
**task** subagents.
|
|
||||||
|
|
||||||
When the user needs work inside a connected product, delegate with **task** and a
|
|
||||||
clear goal. If several Slack channels, Jira projects, calendar calendars, etc. could
|
|
||||||
match and only the integration can list them, **you must not** ask the human for
|
|
||||||
internal IDs (UUIDs, cloud IDs, opaque keys). The **task** subagent uses connector
|
|
||||||
tools to list candidates and either picks the only sensible match or asks the user
|
|
||||||
to choose using **normal labels** (e.g. channel display name, project title), not raw IDs.
|
|
||||||
|
|
||||||
If you already have plain-language choices from the user or from prior tool output,
|
|
||||||
you may pass them through to **task** without re-discovery.
|
|
||||||
</parameter_resolution>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<tools>
|
|
||||||
You have access to the following **SurfSense** tools (main-agent scope only):
|
|
||||||
|
|
||||||
IMPORTANT: You can ONLY use the tools listed below. Anything else — connectors,
|
|
||||||
deliverables, or multi-step integration work — goes through **task**, not as a
|
|
||||||
tool in this list.
|
|
||||||
|
|
||||||
Do NOT claim you can use a capability if it is not listed here.
|
|
||||||
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
|
|
||||||
- scrape_webpage: Fetch and extract readable content from a single HTTP(S) 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/invalid.
|
|
||||||
- Args:
|
|
||||||
- url: Page to fetch
|
|
||||||
- max_length: Cap on returned characters (default: 50000)
|
|
||||||
- Returns: Title, metadata, and markdown-ish body.
|
|
||||||
- Summarize clearly afterward; link back with `[label](url)`.
|
|
||||||
- If indexed workspace material is insufficient and the user points at a public URL, scraping is appropriate — still not a substitute for **task** on private connectors.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
|
|
||||||
- search_surfsense_docs: Search official SurfSense documentation (product help).
|
|
||||||
- Use when the user asks how SurfSense works, setup, connectors at a high level, configuration, etc.
|
|
||||||
- Not a substitute for **task** when they need actions inside Gmail/Slack/Jira/etc.
|
|
||||||
- Args:
|
|
||||||
- query: What to look up in SurfSense docs
|
|
||||||
- top_k: Number of chunks to retrieve (default: 10)
|
|
||||||
- Returns: Doc excerpts; chunk ids may appear for attribution — follow the **citation**
|
|
||||||
instructions block above when citations are enabled; otherwise summarize without `[citation:…]`.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
|
|
||||||
- update_memory: Curate the **personal** long-term memory document for this user.
|
|
||||||
- Current memory (if any) appears in `<user_memory>` with usage vs limit.
|
|
||||||
- Call when the user asks to remember/forget, or shares durable facts/preferences/instructions.
|
|
||||||
- Use the first name from `<user_name>` when writing entries — write “Alex prefers…” not “The user prefers…”.
|
|
||||||
Do not store the name alone as a memory entry.
|
|
||||||
- Skip ephemeral chat noise (one-off q/a, greetings, session logistics).
|
|
||||||
- Args:
|
|
||||||
- updated_memory: FULL replacement markdown (merge and curate — don’t only append).
|
|
||||||
- Formatting rules:
|
|
||||||
- Bullets: `- (YYYY-MM-DD) [marker] text` with markers `[fact]`, `[pref]`, `[instr]` (priority when trimming: instr > pref > fact).
|
|
||||||
- Each bullet under a short `##` heading; keep total size under the limit shown in `<user_memory>`.
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
|
|
||||||
- update_memory: Update the team's shared memory document for this search space.
|
|
||||||
- Your current team memory is already in <team_memory> in your context. The `chars`
|
|
||||||
and `limit` attributes show current usage and the maximum allowed size.
|
|
||||||
- This is the team's curated long-term memory — decisions, conventions, key facts.
|
|
||||||
- NEVER store personal memory in team memory (e.g. personal bio, individual
|
|
||||||
preferences, or user-only standing instructions).
|
|
||||||
- Call update_memory when:
|
|
||||||
* A team member explicitly asks to remember or forget something
|
|
||||||
* The conversation surfaces durable team decisions, conventions, or facts
|
|
||||||
that will matter in future conversations
|
|
||||||
- Do not store short-lived or ephemeral info: one-off questions, greetings,
|
|
||||||
session logistics, or things that only matter for the current task.
|
|
||||||
- Args:
|
|
||||||
- updated_memory: The FULL updated markdown document (not a diff).
|
|
||||||
Merge new facts with existing ones, update contradictions, remove outdated entries.
|
|
||||||
Treat every update as a curation pass — consolidate, don't just append.
|
|
||||||
- Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text
|
|
||||||
Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory.
|
|
||||||
- Keep it concise and well under the character limit shown in <team_memory>.
|
|
||||||
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and
|
|
||||||
natural. Organize by context — e.g. what the team decided, current architecture,
|
|
||||||
active processes. Create, split, or merge headings freely as the memory grows.
|
|
||||||
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant
|
|
||||||
details and context rather than just a few words.
|
|
||||||
- During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
|
|
||||||
- web_search: Live public-web search (whatever search backends the workspace configured).
|
|
||||||
- Use for current events, prices, weather, news, or anything needing fresh public web data.
|
|
||||||
- For those queries, call this tool rather than guessing from memory or claiming you lack network access.
|
|
||||||
- If results are thin, say so and offer to refine the query.
|
|
||||||
- Args:
|
|
||||||
- query: Specific search terms
|
|
||||||
- top_k: Max hits (default: 10, max: 50)
|
|
||||||
- If snippets are too shallow, follow up with **scrape_webpage** on the best URL.
|
|
||||||
- Present sources with readable markdown links `[label](url)` — never bare URLs.
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Main-agent prompt fragments loaded by :mod:`...system_prompt.builder.load_md`."""
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``<citations>`` block — ``on`` (cite chunk ids) and ``off`` (hard suppression)."""
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<citations>
|
||||||
|
Citation markers are **disabled** in this configuration.
|
||||||
|
|
||||||
|
Do NOT include `[citation:…]` markers anywhere, even if tool descriptions or
|
||||||
|
examples reference them. Ignore citation-format reminders elsewhere in this
|
||||||
|
prompt when they conflict with this block.
|
||||||
|
|
||||||
|
1. Answer in plain prose. Optional markdown links to public URLs when
|
||||||
|
sources are URLs.
|
||||||
|
2. Do not expose raw chunk ids, document ids, or internal ids to the user.
|
||||||
|
3. Present KB or docs facts naturally without attribution markers.
|
||||||
|
</citations>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<citations>
|
||||||
|
Apply chunk citations only when the runtime injects `<document>` /
|
||||||
|
`<chunk id='…'>` blocks.
|
||||||
|
|
||||||
|
1. For each factual statement taken from those chunks, add
|
||||||
|
`[citation:chunk_id]` using the exact id from `<chunk id='…'>`.
|
||||||
|
2. Multiple chunks → `[citation:id1], [citation:id2]` (comma-separated).
|
||||||
|
3. Never invent or normalise ids; if unsure, omit.
|
||||||
|
4. Plain brackets only — no markdown links, no footnote numbering.
|
||||||
|
5. If no chunk-tagged documents appear this turn, do not fabricate citations.
|
||||||
|
</citations>
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<core_behavior>
|
||||||
|
- Be concise and direct. No preamble ("Sure!", "Great question!", "I'll now…").
|
||||||
|
- Don't narrate intent — just act. State the outcome, not the plan.
|
||||||
|
- If the request is ambiguous, ask before acting. If asked *how* to do
|
||||||
|
something, explain first, then act.
|
||||||
|
- Prioritise accuracy over agreement. Disagree respectfully when the user is
|
||||||
|
wrong; avoid unnecessary superlatives or emotional validation.
|
||||||
|
- Persist until the task is done or you are genuinely blocked. Don't stop
|
||||||
|
partway and describe what you *would* do.
|
||||||
|
- For longer work, give brief progress updates only when they add new
|
||||||
|
information (a discovery, a tradeoff, a blocker, the start of a non-trivial
|
||||||
|
step). Don't narrate routine reads.
|
||||||
|
</core_behavior>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``<dynamic_context>`` block — private and team variants."""
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<dynamic_context>
|
||||||
|
The runtime inserts these system messages each turn. They are authoritative
|
||||||
|
for *this* turn only.
|
||||||
|
|
||||||
|
`<user_memory>` carries the durable personal context the user has accumulated
|
||||||
|
across sessions — role, interests, preferences, projects, background,
|
||||||
|
standing instructions. It also reports current character usage versus the
|
||||||
|
hard limit so you can manage the budget. Treat it as background colour for
|
||||||
|
your answer, not as the task itself.
|
||||||
|
|
||||||
|
`<priority_documents>` lists the workspace documents most relevant to the
|
||||||
|
latest user message, ranked by relevance score, with `[USER-MENTIONED]`
|
||||||
|
flagged on anything the user explicitly referenced. When the task is about
|
||||||
|
workspace content, read these first; matched passages inside each document
|
||||||
|
are flagged via `<chunk_index>` so you can jump straight to them.
|
||||||
|
|
||||||
|
`<workspace_tree>` shows the full `/documents/` folder and file layout. Use
|
||||||
|
it to resolve paths the user describes in natural language ("my Q2 roadmap",
|
||||||
|
"last week's meeting notes") into concrete document references before
|
||||||
|
delegating to a specialist.
|
||||||
|
|
||||||
|
`<document>` and `<chunk id='…'>` blocks are chunked indexed content returned
|
||||||
|
by KB search (from `search_surfsense_docs`, or backing `<priority_documents>`).
|
||||||
|
Each chunk carries a stable `id` attribute.
|
||||||
|
|
||||||
|
If a block doesn't appear this turn, work from the conversation alone.
|
||||||
|
</dynamic_context>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<dynamic_context>
|
||||||
|
The runtime inserts these system messages each turn. They are authoritative
|
||||||
|
for *this* turn only.
|
||||||
|
|
||||||
|
`<team_memory>` carries the durable shared context this team has built up —
|
||||||
|
decisions, conventions, architecture notes, processes, key facts. It also
|
||||||
|
reports current character usage versus the hard limit so you can manage the
|
||||||
|
budget. Treat it as background colour for your answer, not as the task itself.
|
||||||
|
|
||||||
|
`<priority_documents>` lists the workspace documents most relevant to the
|
||||||
|
latest user message, ranked by relevance score, with `[USER-MENTIONED]`
|
||||||
|
flagged on anything someone in the thread explicitly referenced. When the
|
||||||
|
task is about workspace content, read these first; matched passages inside
|
||||||
|
each document are flagged via `<chunk_index>` so you can jump straight to
|
||||||
|
them.
|
||||||
|
|
||||||
|
`<workspace_tree>` shows the full `/documents/` folder and file layout. Use
|
||||||
|
it to resolve paths described in natural language ("the Q2 roadmap", "last
|
||||||
|
week's planning notes") into concrete document references before delegating
|
||||||
|
to a specialist.
|
||||||
|
|
||||||
|
`<document>` and `<chunk id='…'>` blocks are chunked indexed content returned
|
||||||
|
by KB search (from `search_surfsense_docs`, or backing `<priority_documents>`).
|
||||||
|
Each chunk carries a stable `id` attribute.
|
||||||
|
|
||||||
|
If a block doesn't appear this turn, work from the conversation alone.
|
||||||
|
</dynamic_context>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``<agent_identity>`` block — private and team variants."""
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<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.
|
||||||
|
|
||||||
|
Today (UTC): {resolved_today}
|
||||||
|
</agent_identity>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<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.
|
||||||
|
|
||||||
|
Today (UTC): {resolved_today}
|
||||||
|
|
||||||
|
You are in a **team thread**. Each message is prefixed with `[DisplayName]`.
|
||||||
|
Attribute quotes and decisions to the named author when relevant.
|
||||||
|
</agent_identity>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<knowledge_base_first>
|
||||||
|
CRITICAL — ground factual answers in what you actually receive this turn:
|
||||||
|
- injected workspace context (see `<dynamic_context>`),
|
||||||
|
- results from your own tool calls (`search_surfsense_docs`, `web_search`,
|
||||||
|
`scrape_webpage`),
|
||||||
|
- or substantive summaries returned by a `task` specialist you invoked.
|
||||||
|
|
||||||
|
Do **not** answer factual or informational questions from general knowledge
|
||||||
|
unless the user explicitly authorises it after you say you couldn't find
|
||||||
|
enough in those sources. The flow when nothing is found:
|
||||||
|
|
||||||
|
1. Say you couldn't find enough in their workspace, docs, or tool output.
|
||||||
|
2. Ask: *"Would you like me to answer from my general knowledge instead?"*
|
||||||
|
3. Only answer from general knowledge after a clear yes.
|
||||||
|
|
||||||
|
This rule does NOT apply to: casual conversation · meta-questions about
|
||||||
|
SurfSense ("what can you do?") · formatting or analysis of content already
|
||||||
|
in chat · clear rewrite/edit instructions · lightweight web research.
|
||||||
|
</knowledge_base_first>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``<memory_protocol>`` block — private and team variants."""
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<memory_protocol>
|
||||||
|
After understanding each user message, check: does it reveal durable facts
|
||||||
|
about the user — role, interests, preferences, projects, background, or
|
||||||
|
standing instructions?
|
||||||
|
|
||||||
|
If yes, call `update_memory` **alongside** your normal response — don't
|
||||||
|
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
||||||
|
session logistics). Stay within the budget shown in `<user_memory>`.
|
||||||
|
</memory_protocol>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<memory_protocol>
|
||||||
|
After understanding each user message, check: does it reveal durable facts
|
||||||
|
about the team — decisions, conventions, architecture notes, processes, or
|
||||||
|
key facts?
|
||||||
|
|
||||||
|
If yes, call `update_memory` **alongside** your normal response — don't
|
||||||
|
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
||||||
|
session logistics). Stay within the budget shown in `<team_memory>`.
|
||||||
|
</memory_protocol>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<output_format>
|
||||||
|
- Mathematical formulas: **always** LaTeX. Never backtick code spans or
|
||||||
|
Unicode symbols for math.
|
||||||
|
- Never expose internal tool parameter names, backend IDs, or
|
||||||
|
implementation details. Use natural, user-friendly language.
|
||||||
|
- External sources: markdown links `[label](url)`, never bare URLs.
|
||||||
|
</output_format>
|
||||||
|
|
@ -14,5 +14,5 @@ Workflow (Understand → Plan → Act → Verify):
|
||||||
|
|
||||||
Discipline:
|
Discipline:
|
||||||
- Do not imply access to connectors, MCP tools, or deliverable generators except via **task**.
|
- Do not imply access to connectors, MCP tools, or deliverable generators except via **task**.
|
||||||
- Path arguments for filesystem tools must be exact strings from tool results — never invent paths.
|
- Pass paths to **task(knowledge_base, …)** only when you saw them in `<workspace_tree>` or `<priority_documents>`. Otherwise describe the document in natural language and let the subagent resolve it.
|
||||||
</provider_hints>
|
</provider_hints>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<refusal_and_limits>
|
||||||
|
- If a capability is not in `<tools>` and no entry in `<specialists>` covers
|
||||||
|
it, say so plainly and ask whether the user wants to proceed differently.
|
||||||
|
Don't pretend you can do it.
|
||||||
|
- If a `task` call errors or the specialist is unavailable, surface that to
|
||||||
|
the user with a clear next step. Don't silently retry forever.
|
||||||
|
- Disabled tools announced by the runtime are off-limits even if documented
|
||||||
|
elsewhere — say so and offer a `task` alternative if one exists.
|
||||||
|
- Never claim filesystem access, connector access, or persistent storage you
|
||||||
|
don't have. The four direct tools and the `<specialists>` list are your
|
||||||
|
entire surface area.
|
||||||
|
</refusal_and_limits>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<reminder>
|
||||||
|
Concise · KB-grounded · delegation-first · one `task` per turn · no direct
|
||||||
|
filesystem · persist memory when durable facts appear.
|
||||||
|
</reminder>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<routing>
|
||||||
|
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)
|
||||||
|
- `search_surfsense_docs` — SurfSense product docs (setup, configuration,
|
||||||
|
connector docs, feature behavior).
|
||||||
|
- `web_search` — search the public web (anything outside SurfSense docs and
|
||||||
|
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
|
||||||
|
`in_progress` **before** the `task` call that handles it, `completed`
|
||||||
|
once the call returns. Skip for single-step requests.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### 2. `task(<specialist>, …)` — specialist subagents
|
||||||
|
Use `task` for anything beyond the direct tools above. See
|
||||||
|
`<specialists>` for the live roster.
|
||||||
|
|
||||||
|
Rules for `task`:
|
||||||
|
- **One specialist per `task` call.** A single `task` invocation targets
|
||||||
|
exactly one specialist; that specialist only has tools for its own
|
||||||
|
domain, so any work outside that domain in the same prompt won't run.
|
||||||
|
- **Parallelise independent specialist work.** When a turn needs multiple
|
||||||
|
`task` calls whose work doesn't depend on each other's results (e.g.
|
||||||
|
"create a ClickUp ticket AND a Linear ticket"), emit them as parallel
|
||||||
|
`task` calls. Two `task` calls are independent when:
|
||||||
|
- Neither's prompt references the other's output, and
|
||||||
|
- They target different specialists, OR the same specialist with
|
||||||
|
non-overlapping scopes (e.g. reading two unrelated paths).
|
||||||
|
- **Serialise dependent work across turns.** If one specialist's output
|
||||||
|
must inform another's input (e.g. "find the roadmap in my KB, then
|
||||||
|
email it to Maya"), invoke them on consecutive turns — first finishes,
|
||||||
|
then you call the second with the first's result baked into its prompt.
|
||||||
|
Use `write_todos` to keep the plan alive across those turns.
|
||||||
|
- Within a single specialist, bundle every related step into the same task
|
||||||
|
prompt (read + write + summary go together).
|
||||||
|
- Put the **full instructions inside the task prompt** — the specialist
|
||||||
|
cannot see this thread.
|
||||||
|
- Don't claim to already know what a specialist's source contains; invoke
|
||||||
|
the specialist and use what it returns.
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Save these meeting notes to my KB: …"
|
||||||
|
→ task(knowledge_base, "Save the meeting notes below to a new document
|
||||||
|
under /documents/notes/. Pick a sensible title and folder; tell me the
|
||||||
|
path you used.\n\n<notes>…</notes>")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<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.")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "What's the current USD/INR rate?"
|
||||||
|
→ web_search(query="current USD to INR exchange rate")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Find my Q2 roadmap and summarise the milestones."
|
||||||
|
→ task(knowledge_base, "Locate the Q2 roadmap document under /documents
|
||||||
|
and summarise its milestones. Use glob or grep if the path isn't
|
||||||
|
obvious from the workspace tree.")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Create a ClickUp ticket and a Linear ticket for the new feature flag."
|
||||||
|
→ Independent work — call both specialists in parallel:
|
||||||
|
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.")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Find my Q2 roadmap doc in the KB and email a summary to Maya."
|
||||||
|
→ The email body depends on the doc's contents — serialise across turns.
|
||||||
|
This turn:
|
||||||
|
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>.")
|
||||||
|
</example>
|
||||||
|
</routing>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``<tools>`` block — one vertical-slice subfolder per direct main-agent tool."""
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``scrape_webpage`` — description + few-shot examples."""
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
- `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)`.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``search_surfsense_docs`` — description + few-shot examples."""
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
- `search_surfsense_docs` — Search official SurfSense documentation (product
|
||||||
|
help).
|
||||||
|
- Use when the user asks how SurfSense itself works — setup, configuration,
|
||||||
|
connector documentation, feature behavior, anything covered in the
|
||||||
|
product docs.
|
||||||
|
- Not a substitute for `task` when the user wants actions inside a
|
||||||
|
connected service (Gmail, Slack, Jira, Notion, etc.).
|
||||||
|
- Args: `query`, `top_k` (default 10).
|
||||||
|
- Returns doc excerpts; chunk ids may appear for attribution — see
|
||||||
|
`<citations>` for the contract.
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<example>
|
||||||
|
user: "How do I install SurfSense?"
|
||||||
|
→ search_surfsense_docs(query="installation setup")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "What connectors does SurfSense support?"
|
||||||
|
→ search_surfsense_docs(query="available connectors integrations")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "How do I set up the Notion connector?"
|
||||||
|
→ search_surfsense_docs(query="Notion connector setup configuration")
|
||||||
|
(Changing data inside Notion itself → `task(notion, …)`, not this tool.)
|
||||||
|
</example>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``task`` — description + few-shot examples for the specialist-delegation tool."""
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
- `task` — Invoke a specialist subagent.
|
||||||
|
- Specialists own workspace knowledge-base operations and connected
|
||||||
|
third-party services (Slack, Notion, Jira, Gmail, etc.). See
|
||||||
|
`<specialists>` for the live roster.
|
||||||
|
- Each subagent runs in isolation with its own tool stack and context,
|
||||||
|
and returns a single synthesized result.
|
||||||
|
- Args:
|
||||||
|
- `subagent_type` — name of the specialist to invoke (must match an
|
||||||
|
entry in `<specialists>`).
|
||||||
|
- `description` — the FULL task prompt. The specialist cannot see this
|
||||||
|
thread, so include all context and constraints, plus what you need
|
||||||
|
back. The specialist will respond in its own format — don't dictate
|
||||||
|
one.
|
||||||
|
- Routing rules (when to call, how often, how to scope) live in
|
||||||
|
`<routing>`.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<example>
|
||||||
|
user: "Save these meeting notes to my KB: …"
|
||||||
|
→ task(subagent_type="knowledge_base", description="Save the notes below to
|
||||||
|
a new document under /documents/notes/. Pick a sensible title and folder;
|
||||||
|
tell me the path you used.\n\n<notes>…</notes>")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<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.")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Find my Q2 roadmap and summarise the milestones."
|
||||||
|
→ task(subagent_type="knowledge_base", description="Locate the Q2 roadmap
|
||||||
|
document under /documents and summarise its milestones. Use glob or grep
|
||||||
|
if the path isn't obvious from the workspace tree.")
|
||||||
|
</example>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``update_memory`` — private and team visibility variants."""
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``update_memory`` (private variant) — description + few-shot examples."""
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
- `update_memory` — Curate the **personal** long-term memory document for
|
||||||
|
this user.
|
||||||
|
- The current memory (if any) appears in `<user_memory>` with usage vs limit.
|
||||||
|
- Call when the user asks to remember or forget something, or shares
|
||||||
|
durable facts, preferences, or instructions.
|
||||||
|
- Use the first name from `<user_name>` when writing entries — write
|
||||||
|
"Alex prefers…" not "The user prefers…". Don't store the name alone as a
|
||||||
|
memory entry.
|
||||||
|
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
||||||
|
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
||||||
|
don't only append).
|
||||||
|
- Formatting: bullets `- (YYYY-MM-DD) [marker] text` with markers `[fact]`,
|
||||||
|
`[pref]`, `[instr]` (priority when trimming: `instr > pref > fact`).
|
||||||
|
Group bullets under short `##` headings; stay under the limit shown in
|
||||||
|
`<user_memory>`.
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<example>
|
||||||
|
<user_name>Alex</user_name>, <user_memory> is empty.
|
||||||
|
user: "I'm a space enthusiast, explain astrophage to me"
|
||||||
|
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
|
||||||
|
(Casual durable fact; use first name, neutral heading.)
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Remember that I prefer concise answers over detailed explanations"
|
||||||
|
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
|
||||||
|
(Durable preference; merge with existing memory.)
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "I actually moved to Tokyo last month"
|
||||||
|
→ update_memory(updated_memory="...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
|
||||||
|
(Updated fact; date reflects when recorded.)
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "I'm a freelance photographer working on a nature documentary"
|
||||||
|
→ update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
|
||||||
|
</example>
|
||||||
|
|
||||||
|
<example>
|
||||||
|
user: "Always respond in bullet points"
|
||||||
|
→ update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
|
||||||
|
</example>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""``update_memory`` (team variant) — description + few-shot examples."""
|
||||||
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