Merge remote-tracking branch 'upstream/dev' into fix/zero-cache-stale-replica-1355

This commit is contained in:
Anish Sarkar 2026-05-16 19:30:09 +05:30
commit af1d2fa430
601 changed files with 45027 additions and 4681 deletions

View file

@ -13,5 +13,5 @@ celerybeat-schedule*
celerybeat-schedule.*
celerybeat-schedule.dir
celerybeat-schedule.bak
global_llm_config.yaml
/app/config/global_llm_config.yaml
app/templates/_generated/

View file

@ -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
# Install system dependencies including SSL tools, CUDA dependencies, and Tesseract OCR
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
@ -11,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
unzip \
gnupg2 \
ffmpeg \
espeak-ng \
libsndfile1 \
libgl1 \
@ -22,21 +31,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Pandoc 3.x from GitHub as a fallback for Linux where pypandoc_binary
# 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.
RUN which ffmpeg && ffmpeg -version
# 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) && \
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 && \
rm /tmp/pandoc.deb
# Update certificates and install SSL tools
RUN update-ca-certificates
RUN pip install --upgrade certifi pip-system-certs
# Copy requirements
COPY pyproject.toml .
COPY uv.lock .
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
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.
#
@ -49,9 +64,7 @@ COPY uv.lock .
# 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
# nvidia-cudnn-cu13, nvidia-nccl-cu13, triton, etc. as install deps (all
# captured in uv.lock). Installing from cu121 first only wasted ~2GB of
# downloads that the lock-based install immediately replaced. If a specific
# CUDA version is needed (driver compatibility, etc.), wire it through
# captured in uv.lock). If a specific CUDA version is needed, wire it through
# [tool.uv.sources] in pyproject.toml so the lock stays the source of truth.
RUN pip install --no-cache-dir uv && \
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 && \
rm /tmp/requirements.txt
# Set SSL environment variables dynamically
RUN CERTIFI_PATH=$(python -c "import certifi; print(certifi.where())") && \
echo "Setting SSL_CERT_FILE to $CERTIFI_PATH" && \
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
# ─── Stage 3: models (pre-baked offline assets) ─────────────────────────────
FROM deps AS models
# Pre-download EasyOCR models to avoid runtime SSL issues
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
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
RUN cd /root/.EasyOCR/model && (unzip -o english_g2.zip || true) && (unzip -o craft_mlt_25k.zip || true)
RUN mkdir -p /root/.EasyOCR/model && \
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/pre-v1.1.6/craft_mlt_25k.zip -O /root/.EasyOCR/model/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
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
# is already installed via uv.lock above)
ARG EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
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
# 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.
# 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.
RUN mkdir -p /shared_tmp
ENV TMPDIR=/shared_tmp
# Prevent uvloop compatibility issues
ENV PYTHONPATH=/app
ENV UVICORN_LOOP=asyncio
ENV TMPDIR=/shared_tmp
ENV PYTHONUNBUFFERED=1
# Tune glibc malloc to return freed memory to the OS more aggressively.
# 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_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:
# api FastAPI backend only (runs migrations on startup)
# worker Celery worker only
@ -127,6 +183,5 @@ ENV CELERY_MAX_TASKS_PER_CHILD=50
# "" both queues (default, for single-worker setups)
ENV CELERY_QUEUES=""
# Run
EXPOSE 8000-8001
CMD ["/app/scripts/docker/entrypoint.sh"]
CMD ["/app/scripts/docker/entrypoint.sh"]

View file

@ -67,7 +67,11 @@ def run_migrations_offline() -> 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():
context.run_migrations()

View file

@ -26,6 +26,10 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("agent_action_log"):
return
op.create_table(
"agent_action_log",
sa.Column("id", sa.Integer(), primary_key=True, index=True),

View file

@ -29,6 +29,21 @@ depends_on: str | Sequence[str] | None = 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(
"document_revisions",
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(
"folder_revisions",
sa.Column("id", sa.Integer(), primary_key=True, index=True),

View file

@ -26,6 +26,10 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("agent_permission_rules"):
return
op.create_table(
"agent_permission_rules",
sa.Column("id", sa.Integer(), primary_key=True, index=True),

View file

@ -50,29 +50,39 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"agent_action_log",
sa.Column("tool_call_id", sa.String(length=64), nullable=True),
)
op.add_column(
"agent_action_log",
sa.Column("chat_turn_id", sa.String(length=64), nullable=True),
)
bind = op.get_bind()
inspector = sa.inspect(bind)
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.create_index(
"ix_agent_action_log_tool_call_id",
"agent_action_log",
["tool_call_id"],
)
op.create_index(
"ix_agent_action_log_chat_turn_id",
"agent_action_log",
["chat_turn_id"],
)
if "tool_call_id" not in columns:
op.add_column(
"agent_action_log",
sa.Column("tool_call_id", sa.String(length=64), nullable=True),
)
if "chat_turn_id" not in columns:
op.add_column(
"agent_action_log",
sa.Column("chat_turn_id", sa.String(length=64), nullable=True),
)
op.execute(
"UPDATE agent_action_log SET tool_call_id = turn_id WHERE tool_call_id IS NULL"
)
if "ix_agent_action_log_tool_call_id" not in indexes:
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:

View file

@ -36,15 +36,22 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"new_chat_messages",
sa.Column("turn_id", sa.String(length=64), nullable=True),
)
op.create_index(
"ix_new_chat_messages_turn_id",
"new_chat_messages",
["turn_id"],
)
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {c["name"] for c in inspector.get_columns("new_chat_messages")}
indexes = {i["name"] for i in inspector.get_indexes("new_chat_messages")}
if "turn_id" not in columns:
op.add_column(
"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:

View file

@ -27,6 +27,8 @@ from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "137"
@ -39,6 +41,11 @@ _INDEX_NAME = "ux_agent_action_log_reverse_of"
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
# adding the unique index. Keeps the OLDEST row (smallest id) and
# NULLs out the duplicates' ``reverse_of`` so they survive as audit

View file

@ -53,6 +53,11 @@ TABLE_NAME = "new_chat_messages"
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(
INDEX_NAME,
TABLE_NAME,

View file

@ -25,6 +25,7 @@ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"deliverables": frozenset(),
"knowledge_base": frozenset(),
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
"clickup": frozenset({"CLICKUP_CONNECTOR"}),

View file

@ -11,12 +11,9 @@ from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
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,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from app.agents.new_chat.context import SurfSenseContextSchema
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode
@ -42,7 +39,7 @@ def build_compiled_agent_graph_sync(
flags: AgentFeatureFlags,
checkpointer: Checkpointer,
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,
):
"""Sync compile: middleware + ``create_agent`` (run via ``asyncio.to_thread``)."""

View file

@ -10,7 +10,6 @@ from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from langgraph.types import Checkpointer
from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions
from app.agents.new_chat.agent_cache import (
flags_signature,
get_cache,
@ -25,14 +24,14 @@ from app.db import ChatVisibility
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."""
rows = []
for agent_name in sorted(mcp_tools_by_agent.keys()):
perms = mcp_tools_by_agent[agent_name]
allow_names = sorted(item.get("name", "") for item in perms.get("allow", []))
ask_names = sorted(item.get("name", "") for item in perms.get("ask", []))
rows.append((agent_name, allow_names, ask_names))
names = sorted(
getattr(t, "name", "") or "" for t in mcp_tools_by_agent[agent_name]
)
rows.append((agent_name, names))
return stable_hash(rows)
@ -55,7 +54,7 @@ async def build_agent_with_cache(
flags: AgentFeatureFlags,
checkpointer: Checkpointer,
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,
config_id: str | None,
) -> Any:

View file

@ -7,7 +7,6 @@ import time
from collections.abc import Sequence
from typing import Any
from deepagents.graph import BASE_AGENT_PROMPT
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
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.db import ChatVisibility
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 ..system_prompt import build_main_agent_system_prompt
@ -142,11 +145,49 @@ async def create_multi_agent_chat_deep_agent(
)
mcp_tools_by_agent = {}
_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,
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 []
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
)
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

View file

@ -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

View file

@ -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
``<registry_subagents>`` SurfSense ``<tools>``.
Section order (default flow)::
<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
@ -10,15 +30,18 @@ from datetime import UTC, datetime
from app.db import ChatVisibility
from .load_md import read_prompt_md
from .sections.citations import build_citations_section
from .sections.provider import build_provider_section
from .sections.registry_subagents import build_registry_subagents_section
from .sections.system_instruction import build_default_system_instruction_xml
from .sections.dynamic_context import build_dynamic_context_section
from .sections.identity import build_identity_section
from .sections.memory_protocol import build_memory_protocol_section
from .sections.specialists import build_specialists_section
from .sections.tools import build_tools_section
def build_main_agent_system_prompt(
*,
registry_subagent_prompt_lines: list[tuple[str, str]],
today: datetime | None = None,
thread_visibility: ChatVisibility | 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,
citations_enabled: bool = True,
model_name: str | None = None,
registry_subagent_prompt_lines: list[tuple[str, str]] | None = None,
) -> str:
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
visibility = thread_visibility or ChatVisibility.PRIVATE
if custom_system_instructions and custom_system_instructions.strip():
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 = ""
parts: list[str] = []
system_block += build_provider_section(model_name=model_name)
system_block += build_citations_section(citations_enabled=citations_enabled)
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,
parts.append(
build_identity_section(visibility=visibility, resolved_today=resolved_today)
)
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 ""

View file

@ -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 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:
"""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)
if not ref.is_file():
return ""

View file

@ -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

View file

@ -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
@ -6,6 +6,6 @@ from ..load_md import read_prompt_md
def build_citations_section(*, citations_enabled: bool) -> str:
name = "citations_on.md" if citations_enabled else "citations_off.md"
fragment = read_prompt_md(name)
variant = "on" if citations_enabled else "off"
fragment = read_prompt_md(f"citations/{variant}.md")
return f"\n{fragment}\n" if fragment else ""

View file

@ -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 ""

View file

@ -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"

View file

@ -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 ""

View file

@ -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"
)

View file

@ -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"

View file

@ -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,
)

View file

@ -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

View file

@ -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
@ -13,16 +14,10 @@ from .load_md import read_prompt_md
_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:
return f"tools/{tool_name}_{variant}.md"
return f"tools/{tool_name}.md"
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"
return read_prompt_md(f"tools/{tool_name}/{variant}/{leaf}")
return read_prompt_md(f"tools/{tool_name}/{leaf}")
def _format_tool_label(tool_name: str) -> str:
@ -35,26 +30,35 @@ def build_tools_instruction_block(
enabled_tool_names: set[str] | None,
disabled_tool_names: set[str] | None,
) -> 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"
parts: list[str] = []
preamble = read_prompt_md("tools/_preamble.md")
if preamble:
parts.append(preamble + "\n")
examples: list[str] = []
parts: list[str] = ["\n<tools>\n"]
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:
continue
instruction = read_prompt_md(_tool_fragment_path(tool_name, variant))
if instruction:
parts.append(instruction + "\n")
description = _tool_fragment(tool_name, variant, "description.md")
example = _tool_fragment(tool_name, variant, "example.md")
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:
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 = (
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
)
parts.append(
"\n"
"DISABLED TOOLS (by user, main-agent scope):\n"
f"These SurfSense tools were disabled on the main agent for this session: {disabled_list}.\n"
"You do NOT have access to them and MUST NOT claim you can use them.\n"
"If the user still needs that capability, delegate with **task** if a subagent covers it,\n"
"otherwise explain it is disabled on the main agent for this session.\n"
"<disabled_tools>\n"
f"Disabled for this session: {disabled_list}.\n"
"Don't claim you can use them. If the user needs that capability,\n"
"delegate with `task` when a specialist covers it; otherwise say\n"
"the tool is disabled.\n"
"</disabled_tools>\n"
)
parts.append("\n</tools>\n")
if examples:
parts.append("<tool_call_examples>")
parts.extend(examples)
parts.append("</tool_call_examples>\n")
parts.append("</tools>\n")
return "".join(parts)

View file

@ -1 +0,0 @@
"""Markdown fragments for the **main-agent** system prompt only (`importlib.resources`)."""

View file

@ -1,9 +0,0 @@
You are SurfSenses **main agent**: you answer using the users 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.

View file

@ -1,11 +0,0 @@
You are SurfSenses **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.

View file

@ -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>

View file

@ -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>

View file

@ -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.

View file

@ -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")`

View file

@ -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")

View file

@ -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...")

View file

@ -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")`

View file

@ -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>

View file

@ -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>

View file

@ -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. -->

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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.

View file

@ -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; dont 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.

View file

@ -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:…]`.

View file

@ -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 — dont 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>`.

View file

@ -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.

View file

@ -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.

View file

@ -0,0 +1 @@
"""Main-agent prompt fragments loaded by :mod:`...system_prompt.builder.load_md`."""

View file

@ -0,0 +1 @@
"""``<citations>`` block — ``on`` (cite chunk ids) and ``off`` (hard suppression)."""

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1 @@
"""``<dynamic_context>`` block — private and team variants."""

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1 @@
"""``<agent_identity>`` block — private and team variants."""

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1 @@
"""``<memory_protocol>`` block — private and team variants."""

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -14,5 +14,5 @@ Workflow (Understand → Plan → Act → Verify):
Discipline:
- 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>

View file

@ -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>

View file

@ -0,0 +1,4 @@
<reminder>
Concise · KB-grounded · delegation-first · one `task` per turn · no direct
filesystem · persist memory when durable facts appear.
</reminder>

View file

@ -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>

View file

@ -0,0 +1 @@
"""``<tools>`` block — one vertical-slice subfolder per direct main-agent tool."""

View file

@ -0,0 +1 @@
"""``scrape_webpage`` — description + few-shot examples."""

View file

@ -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)`.

View file

@ -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>

View file

@ -0,0 +1 @@
"""``search_surfsense_docs`` — description + few-shot examples."""

View file

@ -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.

View file

@ -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>

View file

@ -0,0 +1 @@
"""``task`` — description + few-shot examples for the specialist-delegation tool."""

View file

@ -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>`.

View file

@ -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>

View file

@ -0,0 +1 @@
"""``update_memory`` — private and team visibility variants."""

View file

@ -0,0 +1 @@
"""``update_memory`` (private variant) — description + few-shot examples."""

View file

@ -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>`.

View file

@ -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>

View file

@ -0,0 +1 @@
"""``update_memory`` (team variant) — description + few-shot examples."""

View file

@ -0,0 +1,16 @@
- `update_memory` — Curate the team's **shared** long-term memory document
for this search space.
- The current memory (if any) appears in `<team_memory>` with usage vs limit.
- Call when a team member asks to remember or forget something, or when
the conversation surfaces durable team decisions, conventions,
architecture notes, processes, or key facts.
- NEVER store personal memory in team memory (individual bios, personal
preferences, user-only standing instructions).
- 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) [fact] text`. Team memory uses ONLY
the `[fact]` marker (never `[pref]` or `[instr]`). Group bullets under
short `##` headings (2-3 words each); stay under the limit shown in
`<team_memory>`. When trimming, prioritise: decisions/conventions > key
facts > current priorities.

View file

@ -0,0 +1,9 @@
<example>
user: "Let's remember that we decided to do weekly standup meetings on Mondays"
→ update_memory(updated_memory="...\n\n## Team rituals\n- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
</example>
<example>
user: "Our office is in downtown Seattle, 5th floor"
→ update_memory(updated_memory="...\n\n## Workspace\n- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
</example>

View file

@ -0,0 +1 @@
"""``web_search`` — description + few-shot examples."""

View file

@ -0,0 +1,10 @@
- `web_search` — Search the public web.
- Use whenever an answer benefits from external sources — current events,
prices, weather, news, technical references, definitions, background
facts, anything outside SurfSense docs and the workspace KB. Reach for
it whenever freshness matters or you'd otherwise guess from memory.
- Don't refuse with "I lack network access" — call the tool.
- If results are thin, say so and offer to refine the query.
- Args: `query`, `top_k` (default 10, max 50).
- Follow up with `scrape_webpage` on the best URL when snippets are too
shallow. Present sources with `[label](url)` markdown links.

View file

@ -0,0 +1,15 @@
<example>
user: "What's the current USD to INR exchange rate?"
→ web_search(query="current USD to INR exchange rate")
(Answer from snippets; scrape a top URL if needed.)
</example>
<example>
user: "What's the latest news about AI?"
→ web_search(query="latest AI news today")
</example>
<example>
user: "What's the weather in New York?"
→ web_search(query="weather New York today")
</example>

Some files were not shown because too many files have changed in this diff Show more