feat: docs and ui tweaks

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-06 19:26:35 -07:00
parent 64f2b4a6eb
commit 271a21aee6
103 changed files with 2161 additions and 2625 deletions

View file

@ -649,14 +649,31 @@ async def _load_http_mcp_tools(
total_discovered = len(tool_definitions)
if allowed_set:
tool_definitions = [td for td in tool_definitions if td["name"] in allowed_set]
logger.info(
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
url,
connector_id,
len(tool_definitions),
total_discovered,
)
filtered = [td for td in tool_definitions if td["name"] in allowed_set]
if not filtered and total_discovered:
# The server renamed its tools out from under our allowlist
# (e.g. Notion's "notion-" prefix rename) — a fully-dead
# allowlist would silently disable the connector. Load
# everything instead: renamed tools won't match
# ``readonly_tools`` either, so every tool stays HITL-gated.
logger.warning(
"HTTP MCP server '%s' (connector %d): allowlist matched 0/%d "
"advertised tools — server likely renamed its tools. "
"Loading all tools (HITL-gated). Update the registry allowlist: %s",
url,
connector_id,
total_discovered,
sorted(td["name"] for td in tool_definitions),
)
else:
tool_definitions = filtered
logger.info(
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
url,
connector_id,
len(tool_definitions),
total_discovered,
)
else:
logger.info(
"Discovered %d tools from HTTP MCP server '%s' (connector %d) — no allowlist, loading all",

View file

@ -16,6 +16,9 @@ from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.filesystem_selection import FilesystemMode
from app.agents.chat.multi_agent_chat.shared.permissions import Rule, Ruleset
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
append_today_utc,
)
from .middleware_stack import build_kb_middleware
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
@ -57,7 +60,7 @@ def build_subagent(
{
"name": NAME,
"description": load_description(),
"system_prompt": load_system_prompt(filesystem_mode),
"system_prompt": append_today_utc(load_system_prompt(filesystem_mode)),
"model": llm,
"tools": [_build_search_knowledge_base_tool(dependencies)],
"middleware": build_kb_middleware(
@ -86,7 +89,9 @@ def build_readonly_subagent(
{
"name": READONLY_NAME,
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
"system_prompt": load_readonly_system_prompt(filesystem_mode),
"system_prompt": append_today_utc(
load_readonly_system_prompt(filesystem_mode)
),
"model": llm,
"tools": [_build_search_knowledge_base_tool(dependencies)],
"middleware": build_kb_middleware(

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import re
import time as _perf_time
from datetime import UTC, datetime
from typing import Any, cast
from deepagents import SubAgent
@ -60,6 +61,19 @@ def _resolve_includes(prompt: str, *, subagent_name: str) -> str:
return _INCLUDE_DIRECTIVE_RE.sub(_replace, prompt)
def append_today_utc(prompt: str) -> str:
"""Append the current UTC date so subagents share the main agent's clock.
The main agent injects ``Today (UTC): ...`` into its identity prompt, but
delegated subagents (calendar/gmail/drive, date-filtered searches, etc.)
never saw it and would guess the date. Appended at build time; the compiled
graph is cache-keyed on the main prompt hash (which carries the same date),
so a day rollover rebuilds both together and they stay consistent.
"""
today = datetime.now(UTC).date().isoformat()
return f"{prompt}\n\nToday (UTC): {today}\n"
def _user_allowlist_for(
dependencies: dict[str, Any], subagent_name: str
) -> Ruleset | None:
@ -115,6 +129,7 @@ def pack_subagent(
_t0 = _perf_time.perf_counter()
system_prompt = _resolve_includes(system_prompt, subagent_name=name)
system_prompt = append_today_utc(system_prompt)
_t_resolve = _perf_time.perf_counter() - _t0
flags = dependencies["flags"]

View file

@ -28,7 +28,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.capabilities.core.access.rate_limit import enforce_capability_rate_limit
from app.capabilities.core.billing import charge_capability, gate_capability
from app.capabilities.core.billing import (
charge_capability,
gate_capability,
pricing_meters,
)
from app.capabilities.core.events import run_event_bus
from app.capabilities.core.progress import progress_scope
from app.capabilities.core.runs import (
@ -55,13 +59,22 @@ _SSE_HEADERS = {
}
class PricingMeter(BaseModel):
"""One live per-item rate a verb charges on, e.g. 3500 micro-USD per place."""
unit: str
micros_per_unit: int
class CapabilitySummary(BaseModel):
"""A verb's identity + input/output JSON schemas, for the playground UI."""
"""A verb's identity + input/output JSON schemas + pricing, for the playground UI."""
name: str
description: str
input_schema: dict
output_schema: dict
# Empty list = free (billing disabled or an unmetered verb).
pricing: list[PricingMeter] = []
class RunSummary(BaseModel):
@ -125,12 +138,17 @@ def _register_capabilities_list(
) -> None:
"""Register the ``GET`` that lists verbs + their input schemas for the UI."""
summaries = [
CapabilitySummary(
name=capability.name,
description=capability.description,
input_schema=capability.input_schema.model_json_schema(),
output_schema=capability.output_schema.model_json_schema(),
# Schemas are static; pricing is attached per request because rates are
# read live from config (env retune + restart, no rebuild).
base_summaries = [
(
CapabilitySummary(
name=capability.name,
description=capability.description,
input_schema=capability.input_schema.model_json_schema(),
output_schema=capability.output_schema.model_json_schema(),
),
capability.billing_unit,
)
for capability in capabilities
]
@ -141,7 +159,12 @@ def _register_capabilities_list(
auth: AuthContext = Depends(get_auth_context),
) -> list[CapabilitySummary]:
await check_workspace_access(session, auth, workspace_id)
return summaries
return [
summary.model_copy(
update={"pricing": [PricingMeter(**m) for m in pricing_meters(unit)]}
)
for summary, unit in base_summaries
]
router.add_api_route(
"/workspaces/{workspace_id}/scrapers/capabilities",

View file

@ -43,6 +43,53 @@ def _platform_rate(unit: BillingUnit) -> int:
return int(getattr(config, _PLATFORM_RATE_KEYS[unit]))
# Display noun for each platform meter, e.g. "$3.50 / 1k places".
_UNIT_NOUNS: dict[BillingUnit, str] = {
BillingUnit.REDDIT_ITEM: "item",
BillingUnit.GOOGLE_SEARCH_SERP: "SERP",
BillingUnit.GOOGLE_MAPS_PLACE: "place",
BillingUnit.GOOGLE_MAPS_REVIEW: "review",
BillingUnit.YOUTUBE_VIDEO: "video",
BillingUnit.YOUTUBE_COMMENT: "comment",
}
def pricing_meters(unit: BillingUnit | None) -> list[dict]:
"""The live per-item rates a verb charges, for UI display. Empty = free.
Mirrors the gate/charge logic exactly: meters whose billing flag is off are
omitted, so a self-hosted install with billing disabled reads as free.
"""
if unit is None:
return []
if unit is BillingUnit.WEB_CRAWL:
meters = []
if WebCrawlCreditService.billing_enabled():
meters.append(
{"unit": "page", "micros_per_unit": config.WEB_CRAWL_MICROS_PER_SUCCESS}
)
if WebCrawlCreditService.captcha_billing_enabled() and captcha_enabled():
meters.append(
{
"unit": "captcha solve",
"micros_per_unit": config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE,
}
)
return meters
if not config.PLATFORM_SCRAPE_BILLING_ENABLED:
return []
meters = [{"unit": _UNIT_NOUNS[unit], "micros_per_unit": _platform_rate(unit)}]
if unit is BillingUnit.GOOGLE_MAPS_PLACE:
# Dual-metered: attached reviews bill on their own meter.
meters.append(
{
"unit": _UNIT_NOUNS[BillingUnit.GOOGLE_MAPS_REVIEW],
"micros_per_unit": _platform_rate(BillingUnit.GOOGLE_MAPS_REVIEW),
}
)
return meters
async def gate_capability(
payload: BillableInput, unit: BillingUnit | None, ctx: CapabilityContext
) -> None:

View file

@ -153,13 +153,26 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
"mpim:history",
"im:history",
],
# Both prefixed and unprefixed variants: sources disagree on which
# names mcp.slack.com currently advertises. Unmatched entries are
# ignored at discovery time.
allowed_tools=[
"slack_search_channels",
"slack_read_channel",
"slack_read_thread",
"search_channels",
"read_channel",
"read_thread",
],
readonly_tools=frozenset(
{"slack_search_channels", "slack_read_channel", "slack_read_thread"}
{
"slack_search_channels",
"slack_read_channel",
"slack_read_thread",
"search_channels",
"read_channel",
"read_thread",
}
),
# TODO: oauth.v2.user.access only returns team.id, not team.name.
# To populate team_name, either add "team:read" scope and call
@ -192,13 +205,22 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
# DCR (RFC 7591): Notion issues its own client credentials. It expires
# DCR registrations, but refresh reuses the original persisted
# ``mcp_oauth.client_id`` (see _refresh_connector_token).
# Notion renamed its MCP tools with a "notion-" prefix (late 2025).
# Unprefixed names kept for servers still advertising the old names —
# allowlist entries the server doesn't advertise are simply ignored.
allowed_tools=[
"notion-search",
"notion-fetch",
"notion-create-pages",
"notion-update-page",
"search",
"fetch",
"create-pages",
"update-page",
],
readonly_tools=frozenset({"search", "fetch"}),
readonly_tools=frozenset(
{"notion-search", "notion-fetch", "search", "fetch"}
),
account_metadata_keys=["workspace_name"],
),
"confluence": MCPServiceConfig(

View file

@ -30,6 +30,12 @@ DEPRECATED_CONNECTOR_TYPES: frozenset[str] = frozenset(
"SEARXNG_API",
"LINKUP_API",
"BAIDU_SEARCH_API",
# Legacy content crawlers/search superseded by the file Import menu and
# hosted MCP tooling. Created via the generic connector /add route, which
# is the single choke point enforcing this deprecation.
"YOUTUBE_CONNECTOR",
"WEBCRAWLER_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
}
)