This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-15 11:55:40 -07:00
commit dc88ce0277
193 changed files with 6934 additions and 2192 deletions

View file

@ -14,9 +14,6 @@ from langgraph.types import Checkpointer
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,12 @@ 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 +52,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

@ -29,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
@ -141,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:

View file

@ -49,7 +49,7 @@ def build_main_agent_system_prompt(
custom_system_instructions: str | None = None,
use_default_system_instructions: bool = True,
citations_enabled: bool = True,
model_name: str | None = None, # noqa: ARG001 — kept for caller compatibility
model_name: str | None = None,
) -> str:
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
visibility = thread_visibility or ChatVisibility.PRIVATE
@ -62,7 +62,9 @@ def build_main_agent_system_prompt(
if custom_system_instructions and custom_system_instructions.strip():
parts.append(
"\n" + custom_system_instructions.format(resolved_today=resolved_today) + "\n"
"\n"
+ custom_system_instructions.format(resolved_today=resolved_today)
+ "\n"
)
if use_default_system_instructions:

View file

@ -23,15 +23,21 @@ 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 must
describe work that one specialist can do end-to-end. Never bundle work
for two specialists into one task prompt — the specialist you route to
will silently drop the other half.
- **One `task` call per turn.** If the user's request spans multiple
specialists, handle them one at a time across consecutive turns: invoke
the first this turn, return, then invoke the next on your next turn (no
user input required between). Use `write_todos` to keep the plan alive
across those turns.
- **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
@ -66,19 +72,25 @@ user: "Find my Q2 roadmap and summarise the milestones."
<example>
user: "Create a ClickUp ticket and a Linear ticket for the new feature flag."
This turn:
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: "pending"},
{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.")
→ Next turn:
write_todos([
{content: "Create ClickUp ticket for feature flag rollout", status: "completed"},
{content: "Create Linear ticket for feature flag rollout", status: "in_progress"},
])
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

@ -4,21 +4,27 @@ Replaces upstream ``SubAgentMiddleware`` to:
- share the parent's checkpointer with each subagent,
- forward ``runtime.config`` (thread_id, recursion_limit, ) into nested invokes,
- isolate each parallel ``task`` call in its own checkpoint slot via
per-call ``thread_id`` namespacing,
- bridge ``Command(resume=...)`` from the parent into the subagent via the
``config["configurable"]["surfsense_resume_value"]`` side-channel,
``config["configurable"]["surfsense_resume_value"]`` side-channel, keyed by
``tool_call_id`` so parallel siblings never race on a shared scalar,
- target the resume at the captured interrupt id so a follow-up
``HumanInTheLoopMiddleware.after_model`` does not consume the same payload,
- re-raise any new subagent interrupt at the parent so the SSE stream surfaces it.
- stamp each subagent's pending interrupt with the parent's ``tool_call_id``
so ``stream_resume_chat`` can route a flat ``decisions`` list back to the
right paused subagent.
Module layout
-------------
- ``constants`` shared keys / limits.
- ``config`` RunnableConfig + side-channel resume read.
- ``resume`` pending-interrupt detection, fan-out, ``Command(resume=...)`` builder.
- ``propagation`` re-raise pending subagent interrupts at the parent.
- ``task_tool`` the ``task`` tool factory (sync + async).
- ``middleware`` :class:`SurfSenseCheckpointedSubAgentMiddleware` itself.
- ``constants`` shared keys / limits.
- ``config`` RunnableConfig + side-channel resume read + per-call ``thread_id``.
- ``resume`` pending-interrupt detection, fan-out, ``Command(resume=...)`` builder.
- ``propagation`` ``wrap_with_tool_call_id`` helper for stamping interrupt values.
- ``resume_routing`` slice a flat decisions list to per-``tool_call_id`` payloads.
- ``task_tool`` the ``task`` tool factory (sync + async), and the catch-and-stamp chokepoint.
- ``middleware`` :class:`SurfSenseCheckpointedSubAgentMiddleware` itself.
"""
from .middleware import SurfSenseCheckpointedSubAgentMiddleware

View file

@ -21,7 +21,17 @@ _LANGGRAPH_SCRATCHPAD_KEY = "__pregel_scratchpad"
def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]:
"""RunnableConfig for the nested invoke; raises ``recursion_limit`` to the parent's budget."""
"""RunnableConfig for the nested invoke; raises ``recursion_limit`` and isolates ``thread_id``.
Each parallel subagent invocation lands in its own checkpoint slot keyed
by an extended ``thread_id`` of the form ``{parent_thread}::task:{tool_call_id}``.
The same call across the resume cycle keeps reading from the same snapshot
(``tool_call_id`` is stable per LLM-emitted call).
We namespace via ``thread_id`` rather than ``checkpoint_ns`` because
langgraph's ``aget_state`` interprets a non-empty ``checkpoint_ns`` as a
subgraph path and raises ``ValueError("Subgraph X not found")``.
"""
merged: dict[str, Any] = dict(runtime.config) if runtime.config else {}
current_limit = merged.get("recursion_limit")
try:
@ -30,43 +40,68 @@ def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]:
current_int = 0
if current_int < DEFAULT_SUBAGENT_RECURSION_LIMIT:
merged["recursion_limit"] = DEFAULT_SUBAGENT_RECURSION_LIMIT
configurable: dict[str, Any] = dict(merged.get("configurable") or {})
parent_thread_id = configurable.get("thread_id")
per_call_suffix = f"task:{runtime.tool_call_id}"
configurable["thread_id"] = (
f"{parent_thread_id}::{per_call_suffix}"
if parent_thread_id
else per_call_suffix
)
merged["configurable"] = configurable
return merged
def consume_surfsense_resume(runtime: ToolRuntime) -> Any:
"""Pop the resume payload; siblings share ``configurable`` by reference."""
"""Pop the resume payload for *this* call's ``tool_call_id``.
The configurable holds ``surfsense_resume_value: dict[tool_call_id, payload]``
so parallel sibling subagents (each with their own ``tool_call_id``) read
only their own decision and never race on a shared scalar.
"""
cfg = runtime.config or {}
configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
if not isinstance(configurable, dict):
return None
return configurable.pop("surfsense_resume_value", None)
by_tcid = configurable.get("surfsense_resume_value")
if not isinstance(by_tcid, dict):
return None
payload = by_tcid.pop(runtime.tool_call_id, None)
if not by_tcid:
configurable.pop("surfsense_resume_value", None)
return payload
def has_surfsense_resume(runtime: ToolRuntime) -> bool:
"""True iff a resume payload is queued on this runtime (non-destructive)."""
"""True iff a resume payload for this call's ``tool_call_id`` is queued (non-destructive)."""
cfg = runtime.config or {}
configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
if not isinstance(configurable, dict):
return False
return "surfsense_resume_value" in configurable
by_tcid = configurable.get("surfsense_resume_value")
if not isinstance(by_tcid, dict):
return False
return runtime.tool_call_id in by_tcid
def drain_parent_null_resume(runtime: ToolRuntime) -> None:
"""Consume the parent's lingering ``NULL_TASK_ID/RESUME`` write before delegating.
``stream_resume_chat`` wakes the main agent with
``Command(resume={"decisions": [...]})`` so the propagated
``_lg_interrupt(...)`` can return. langgraph stores that payload as the
parent task's ``null_resume`` pending write, which only gets consumed
*after* ``subagent.[a]invoke`` returns (when the post-call propagation
re-fires). While the subagent is mid-execution, any *new* ``interrupt()``
inside it (e.g. a follow-up tool call after a mixed approve/reject) walks
``subagent_scratchpad parent_scratchpad.get_null_resume`` and picks up
the parent's still-live decisions — mismatching against a different number
of hanging tool calls and crashing ``HumanInTheLoopMiddleware``.
``Command(resume={tool_call_id: {"decisions": [...]}})`` so the previously
propagated parent-level interrupt can return. langgraph stores that
payload as the parent task's ``null_resume`` pending write. The ``task``
tool then forwards this turn's slice into the subagent via its own
``Command(resume=...)``. While the subagent is mid-execution, any *new*
``interrupt()`` inside it (e.g. a follow-up tool call after a mixed
approve/reject) walks ``subagent_scratchpad parent_scratchpad.get_null_resume``
and picks up the parent's still-live decisions — mismatching against a
different number of hanging tool calls and crashing
``HumanInTheLoopMiddleware``.
Draining the write here closes that cross-graph leak so subagent
interrupts pause cleanly and re-propagate as a fresh approval card.
interrupts pause cleanly and bubble back up as a fresh approval card.
"""
cfg = runtime.config or {}
configurable = cfg.get("configurable") if isinstance(cfg, dict) else None

View file

@ -12,7 +12,6 @@ from deepagents.middleware.subagents import (
SubAgentMiddleware,
)
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.chat_models import init_chat_model
from langgraph.types import Checkpointer
@ -81,10 +80,6 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
middleware: list[Any] = list(spec.get("middleware", []))
interrupt_on = spec.get("interrupt_on")
if interrupt_on:
middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on))
specs.append(
{
"name": spec["name"],

View file

@ -1,74 +1,38 @@
"""Re-raise still-pending subagent interrupts at the parent graph level.
"""Stamp the parent's ``tool_call_id`` onto a subagent's pending interrupt value.
After ``subagent.[a]invoke(Command(resume=...))`` returns, the subagent may
still hold a pending interrupt (e.g. the LLM produced a follow-up tool call
that fired a fresh ``interrupt()``). The parent's pregel cannot see that
interrupt because it lives in a separate compiled graph; we re-raise it here
so the parent's SSE stream surfaces it as the next approval card.
When a subagent (compiled as a langgraph subgraph and invoked from a parent
tool node) hits an ``interrupt(...)`` from its HITL middleware, langgraph
raises ``GraphInterrupt`` out of ``subagent.[a]invoke(...)``. The parent's
``task`` tool catches that exception, stamps ``tool_call_id`` onto each
``Interrupt.value`` using :func:`wrap_with_tool_call_id`, and re-raises a
fresh ``GraphInterrupt`` whose values carry that stamp.
``stream_resume_chat`` then reads ``parent.state.interrupts[*].value["tool_call_id"]``
to route a flat ``decisions`` list back to the right paused subagent without
the stamp, parallel HITL across siblings would collapse into an ambiguous
bucket and resume would fail.
This module hosts only the stamping helper; the catch/re-raise lives in
``task_tool.py`` since that's the single chokepoint where the raw exception
is in our hands.
"""
from __future__ import annotations
import logging
from typing import Any
from langchain_core.runnables import Runnable
from langgraph.types import interrupt as _lg_interrupt
from .resume import get_first_pending_subagent_interrupt
def wrap_with_tool_call_id(value: Any, tool_call_id: str) -> dict[str, Any]:
"""Return a value dict that always carries the parent's ``tool_call_id``.
logger = logging.getLogger(__name__)
Dict values are shallow-copied with ``tool_call_id`` stamped on top, so
any value the subagent may already carry under that key (from a deeper
HITL level) is overwritten the parent's call id is the only one
``stream_resume_chat`` correlates against.
def maybe_propagate_subagent_interrupt(
subagent: Runnable,
sub_config: dict[str, Any],
subagent_type: str,
) -> None:
"""Re-raise a still-pending subagent interrupt at the parent so the SSE stream surfaces it."""
get_state_sync = getattr(subagent, "get_state", None)
if not callable(get_state_sync):
return
try:
snapshot = get_state_sync(sub_config)
except Exception: # pragma: no cover - defensive
logger.debug(
"Subagent get_state failed during re-interrupt check",
exc_info=True,
)
return
_pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot)
if pending_value is None:
return
logger.info(
"Re-raising subagent %r interrupt to parent (multi-step HITL)",
subagent_type,
)
_lg_interrupt(pending_value)
async def amaybe_propagate_subagent_interrupt(
subagent: Runnable,
sub_config: dict[str, Any],
subagent_type: str,
) -> None:
"""Async counterpart of :func:`maybe_propagate_subagent_interrupt`."""
aget_state = getattr(subagent, "aget_state", None)
if not callable(aget_state):
return
try:
snapshot = await aget_state(sub_config)
except Exception: # pragma: no cover - defensive
logger.debug(
"Subagent aget_state failed during re-interrupt check",
exc_info=True,
)
return
_pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot)
if pending_value is None:
return
logger.info(
"Re-raising subagent %r interrupt to parent (multi-step HITL)",
subagent_type,
)
_lg_interrupt(pending_value)
Non-dict values are wrapped as ``{"value": <original>, "tool_call_id": ...}``
so simple ``interrupt("approve?")`` patterns still propagate cleanly.
"""
if isinstance(value, dict):
return {**value, "tool_call_id": tool_call_id}
return {"value": value, "tool_call_id": tool_call_id}

View file

@ -0,0 +1,183 @@
"""Route a flat ``decisions`` list to per-``tool_call_id`` resume payloads.
The frontend submits decisions in the same order the SSE stream emitted
approval cards. When multiple parallel subagents are paused, the backend uses
this module to:
1. Read ``state.interrupts`` from the parent's paused snapshot, extracting
``[(tool_call_id, action_count), ...]`` from each interrupt's value.
The ``tool_call_id`` is stamped on by ``propagation.wrap_with_tool_call_id``
inside ``task_tool``'s catch-and-stamp block when a subagent's
``GraphInterrupt`` bubbles up through ``[a]task``.
2. Slice the flat ``decisions`` list against that ordered pending list to
produce the dict shape expected by ``consume_surfsense_resume``.
3. Re-key those slices by ``Interrupt.id`` (langgraph's primitive) for use as
the parent-level ``Command(resume={interrupt_id: payload})`` input the
only shape langgraph accepts when multiple interrupts are pending.
All helpers are pure: callers own the state and the input decisions; we
return new structures and never mutate.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from typing import Any
logger = logging.getLogger(__name__)
def slice_decisions_by_tool_call(
decisions: list[dict[str, Any]],
pending: Iterable[tuple[str, int]],
) -> dict[str, dict[str, Any]]:
"""Slice ``decisions`` into ``{tool_call_id: {"decisions": <slice>}}``.
Args:
decisions: Flat list of decisions in the order the SSE stream rendered
them.
pending: Ordered ``(tool_call_id, action_count)`` pairs in the same
order. The slicer consumes ``decisions`` left-to-right.
Returns:
Per-``tool_call_id`` payload dict ready to be written to
``configurable["surfsense_resume_value"]``.
Raises:
ValueError: When the total expected action count differs from the
number of decisions provided. We fail loud rather than silently
dropping or padding so a frontend/backend contract drift surfaces
immediately.
"""
pending_list = list(pending)
expected = sum(count for _, count in pending_list)
if expected != len(decisions):
raise ValueError(
f"Decision count mismatch: pending tool calls expect "
f"{expected} actions but received {len(decisions)} decisions."
)
routed: dict[str, dict[str, Any]] = {}
cursor = 0
for tool_call_id, action_count in pending_list:
routed[tool_call_id] = {"decisions": decisions[cursor : cursor + action_count]}
cursor += action_count
return routed
def collect_pending_tool_calls(state: Any) -> list[tuple[str, int]]:
"""Extract ``[(tool_call_id, action_count), ...]`` from a paused parent state.
Reads ``state.interrupts`` (the bundle langgraph aggregated from each
paused subagent's propagated interrupt). Each interrupt value carries the
``tool_call_id`` that the parent's ``task`` tool was processing — see
``propagation.wrap_with_tool_call_id`` and ``task_tool``'s
``except GraphInterrupt`` chokepoint.
Order is preserved from ``state.interrupts``, which is the order the SSE
stream emitted approval cards. The frontend submits decisions in that
same order, so the slicer can consume them left-to-right.
Interrupts without a ``tool_call_id`` are skipped they were not
produced by our task-routing layer (e.g. parent-side HITL middleware on
a different tool); ``stream_resume_chat`` is not responsible for routing
those.
Args:
state: A langgraph ``StateSnapshot`` (or any object with an
``interrupts`` attribute).
Returns:
Ordered list of ``(tool_call_id, action_count)``. ``action_count`` is
``len(value["action_requests"])`` for HITL-bundle values, or ``1`` for
scalar-style ``interrupt("...")`` values that were wrapped as
``{"value": ..., "tool_call_id": ...}``.
Raises:
ValueError: When an interrupt value carries a ``tool_call_id`` but
the action count cannot be determined (contract bug every
propagated value should be either a HITL bundle or a wrapped
scalar).
"""
pending: list[tuple[str, int]] = []
for idx, interrupt_obj in enumerate(getattr(state, "interrupts", ()) or ()):
value = getattr(interrupt_obj, "value", None)
if not isinstance(value, dict):
logger.warning(
"[hitl_route] interrupt[%d] skipped: value not a dict (type=%s)",
idx,
type(value).__name__,
)
continue
tool_call_id = value.get("tool_call_id")
if not isinstance(tool_call_id, str):
# Should not happen post-stamping; flag loudly if a regression
# ever lets an unstamped value reach the parent state.
logger.warning(
"[hitl_route] interrupt[%d] skipped: no tool_call_id stamp (keys=%s)",
idx,
sorted(value.keys()),
)
continue
action_requests = value.get("action_requests")
if isinstance(action_requests, list):
pending.append((tool_call_id, len(action_requests)))
continue
if "value" in value:
pending.append((tool_call_id, 1))
continue
raise ValueError(
f"Interrupt for tool_call_id={tool_call_id!r} has no "
"``action_requests`` list and is not a wrapped scalar value; "
"cannot determine action count for resume routing."
)
return pending
def build_lg_resume_map(
state: Any, by_tool_call_id: dict[str, dict[str, Any]]
) -> dict[str, dict[str, Any]]:
"""Map ``Interrupt.id → resume_payload`` for langgraph's multi-interrupt resume.
``stream_resume_chat`` builds ``by_tool_call_id`` via
:func:`slice_decisions_by_tool_call`. Langgraph's ``Command(resume=...)``
requires ``Interrupt.id`` keys (not our ``tool_call_id`` stamps) when the
parent state has multiple pending interrupts. This pure helper re-keys the
slice without mutating it, and skips entries that can't be paired (no
stamp, no slice) so contract drift surfaces as a count mismatch at the
call site instead of a silent mis-route.
The two key spaces serve two different consumers:
- ``surfsense_resume_value`` (keyed by ``tool_call_id``): read by the
subagent bridge inside ``task_tool``.
- ``Command(resume=...)`` (keyed by ``Interrupt.id``): read by langgraph's
pregel to wake each pending interrupt site.
Args:
state: A langgraph ``StateSnapshot`` (or any object with an
``interrupts`` iterable).
by_tool_call_id: Output of :func:`slice_decisions_by_tool_call`.
Returns:
Dict ready to be passed as ``Command(resume=<this>)``.
"""
out: dict[str, dict[str, Any]] = {}
for interrupt_obj in getattr(state, "interrupts", ()) or ():
value = getattr(interrupt_obj, "value", None)
if not isinstance(value, dict):
continue
tool_call_id = value.get("tool_call_id")
if not isinstance(tool_call_id, str):
continue
interrupt_id = getattr(interrupt_obj, "id", None)
if not isinstance(interrupt_id, str):
continue
payload = by_tool_call_id.get(tool_call_id)
if payload is None:
continue
out[interrupt_id] = payload
return out

View file

@ -9,14 +9,15 @@ re-raises any new pending interrupt back to the parent.
from __future__ import annotations
import logging
from typing import Annotated, Any
from typing import Annotated, Any, NoReturn
from deepagents.middleware.subagents import TASK_TOOL_DESCRIPTION
from langchain.tools import BaseTool, ToolRuntime
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.runnables import Runnable
from langchain_core.tools import StructuredTool
from langgraph.types import Command
from langgraph.errors import GraphInterrupt
from langgraph.types import Command, Interrupt
from .config import (
consume_surfsense_resume,
@ -25,10 +26,7 @@ from .config import (
subagent_invoke_config,
)
from .constants import EXCLUDED_STATE_KEYS
from .propagation import (
amaybe_propagate_subagent_interrupt,
maybe_propagate_subagent_interrupt,
)
from .propagation import wrap_with_tool_call_id
from .resume import (
build_resume_command,
fan_out_decisions_to_match,
@ -39,6 +37,31 @@ from .resume import (
logger = logging.getLogger(__name__)
def _reraise_stamped_subagent_interrupt(
gi: GraphInterrupt, tool_call_id: str
) -> NoReturn:
"""Stamp ``tool_call_id`` onto each pending interrupt value and re-raise.
See :mod:`...propagation` for why this stamp is required for resume routing.
Chained via ``from gi`` so tracebacks point at the subagent's original
``interrupt(...)`` site.
"""
interrupts = gi.args[0] if gi.args else ()
stamped = tuple(
Interrupt(
value=wrap_with_tool_call_id(i.value, tool_call_id),
id=i.id,
)
for i in interrupts
)
logger.info(
"[hitl_route] stamped %d subagent interrupt(s) with tool_call_id=%s",
len(stamped),
tool_call_id,
)
raise GraphInterrupt(stamped) from gi
def build_task_tool_with_parent_config(
subagents: list[dict[str, Any]],
task_description: str | None = None,
@ -161,13 +184,18 @@ def build_task_tool_with_parent_config(
# Prevent the parent's resume payload from leaking into subagent
# interrupts via langgraph's parent_scratchpad fallback.
drain_parent_null_resume(runtime)
result = subagent.invoke(
build_resume_command(resume_value, pending_id),
config=sub_config,
)
try:
result = subagent.invoke(
build_resume_command(resume_value, pending_id),
config=sub_config,
)
except GraphInterrupt as gi:
_reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
else:
result = subagent.invoke(subagent_state, config=sub_config)
maybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
try:
result = subagent.invoke(subagent_state, config=sub_config)
except GraphInterrupt as gi:
_reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
return _return_command_with_state_update(result, runtime.tool_call_id)
async def atask(
@ -181,6 +209,11 @@ def build_task_tool_with_parent_config(
],
runtime: ToolRuntime,
) -> str | Command:
logger.info(
"[hitl_route] atask ENTRY: subagent_type=%r tool_call_id=%s",
subagent_type,
runtime.tool_call_id,
)
if subagent_type not in subagent_graphs:
allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs])
return (
@ -228,13 +261,18 @@ def build_task_tool_with_parent_config(
# Prevent the parent's resume payload from leaking into subagent
# interrupts via langgraph's parent_scratchpad fallback.
drain_parent_null_resume(runtime)
result = await subagent.ainvoke(
build_resume_command(resume_value, pending_id),
config=sub_config,
)
try:
result = await subagent.ainvoke(
build_resume_command(resume_value, pending_id),
config=sub_config,
)
except GraphInterrupt as gi:
_reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
else:
result = await subagent.ainvoke(subagent_state, config=sub_config)
await amaybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
try:
result = await subagent.ainvoke(subagent_state, config=sub_config)
except GraphInterrupt as gi:
_reraise_stamped_subagent_interrupt(gi, runtime.tool_call_id)
return _return_command_with_state_update(result, runtime.tool_call_id)
return StructuredTool.from_function(

View file

@ -22,7 +22,7 @@ if TYPE_CHECKING:
def check_cloud_write_namespace(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
path: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str | None:

View file

@ -25,7 +25,7 @@ if TYPE_CHECKING:
def current_cwd(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:
cwd = runtime.state.get("cwd") if hasattr(runtime, "state") else None
@ -35,7 +35,7 @@ def current_cwd(
def get_contract_suggested_path(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:
"""Read the planner's suggested write path; otherwise default to ``notes.md``."""
@ -47,7 +47,7 @@ def get_contract_suggested_path(
def resolve_relative(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
path: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:
@ -63,7 +63,7 @@ def resolve_relative(
def resolve_write_target_path(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
file_path: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:
@ -77,7 +77,7 @@ def resolve_write_target_path(
def resolve_move_target_path(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
file_path: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:
@ -91,7 +91,7 @@ def resolve_move_target_path(
def resolve_list_target_path(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
path: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:
@ -105,7 +105,7 @@ def resolve_list_target_path(
def normalize_local_mount_path(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
candidate: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> str:

View file

@ -9,9 +9,7 @@ from .common import HEADER, SANDBOX_ADDENDUM
from .desktop import BODY as DESKTOP_BODY
def build_system_prompt(
mode: FilesystemMode, *, sandbox_available: bool
) -> str:
def build_system_prompt(mode: FilesystemMode, *, sandbox_available: bool) -> str:
"""Assemble the FS prompt: common header + mode body + optional sandbox section."""
body = CLOUD_BODY if mode == FilesystemMode.CLOUD else DESKTOP_BODY
base = HEADER + body

View file

@ -21,7 +21,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_cd_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_cd_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_cd(

View file

@ -24,7 +24,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_edit_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_edit_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_edit_file(

View file

@ -36,7 +36,7 @@ def wrap_as_python(code: str) -> str:
async def execute_in_sandbox(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
command: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
timeout: int | None,
@ -59,14 +59,12 @@ async def execute_in_sandbox(
try:
return await _try_sandbox_execute(mw, command, runtime, timeout)
except Exception:
logger.exception(
"Sandbox retry also failed for thread %s", mw._thread_id
)
logger.exception("Sandbox retry also failed for thread %s", mw._thread_id)
return "Error: Code execution is temporarily unavailable. Please try again."
async def _try_sandbox_execute(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
command: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
timeout: int | None,

View file

@ -17,13 +17,11 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_execute_code_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_execute_code_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
def sync_execute_code(
command: Annotated[
str, "Python code to execute. Use print() to see output."
],
command: Annotated[str, "Python code to execute. Use print() to see output."],
runtime: ToolRuntime[None, SurfSenseFilesystemState],
timeout: Annotated[
int | None,
@ -35,14 +33,10 @@ def create_execute_code_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
return f"Error: timeout must be non-negative, got {timeout}."
if timeout > MAX_EXECUTE_TIMEOUT:
return f"Error: timeout {timeout}s exceeds maximum ({MAX_EXECUTE_TIMEOUT}s)."
return run_async_blocking(
execute_in_sandbox(mw, command, runtime, timeout)
)
return run_async_blocking(execute_in_sandbox(mw, command, runtime, timeout))
async def async_execute_code(
command: Annotated[
str, "Python code to execute. Use print() to see output."
],
command: Annotated[str, "Python code to execute. Use print() to see output."],
runtime: ToolRuntime[None, SurfSenseFilesystemState],
timeout: Annotated[
int | None,

View file

@ -20,7 +20,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_list_tree_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_list_tree_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_list_tree(

View file

@ -19,7 +19,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_ls_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_ls_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_ls(

View file

@ -23,7 +23,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_mkdir_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_mkdir_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_mkdir(

View file

@ -18,7 +18,7 @@ if TYPE_CHECKING:
async def cloud_move_file(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
source: str,
dest: str,
@ -39,8 +39,7 @@ async def cloud_move_file(
)
if not source.startswith(DOCUMENTS_ROOT + "/"):
return (
"Error: cloud move_file source must be under /documents/ (got "
f"'{source}')."
f"Error: cloud move_file source must be under /documents/ (got '{source}')."
)
if not dest.startswith(DOCUMENTS_ROOT + "/"):
return (
@ -89,9 +88,7 @@ async def cloud_move_file(
],
"messages": [
ToolMessage(
content=(
f"Moved '{source}' to '{dest}' (will commit at end of turn)."
),
content=(f"Moved '{source}' to '{dest}' (will commit at end of turn)."),
tool_call_id=runtime.tool_call_id,
)
],

View file

@ -23,7 +23,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_move_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_move_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_move_file(
@ -85,9 +85,7 @@ def create_move_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
] = False,
) -> Command | str:
return run_async_blocking(
async_move_file(
source_path, destination_path, runtime, overwrite=overwrite
)
async_move_file(source_path, destination_path, runtime, overwrite=overwrite)
)
return StructuredTool.from_function(

View file

@ -16,7 +16,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_pwd_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_pwd_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
def sync_pwd(

View file

@ -21,7 +21,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_read_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_read_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_read_file(
@ -90,9 +90,7 @@ def create_read_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
"Maximum number of lines to read.",
] = 100,
) -> Command | str:
return run_async_blocking(
async_read_file(file_path, runtime, offset, limit)
)
return run_async_blocking(async_read_file(file_path, runtime, offset, limit))
return StructuredTool.from_function(
name="read_file",

View file

@ -22,7 +22,7 @@ if TYPE_CHECKING:
async def cloud_rm(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
validated: str,
) -> Command | str:
@ -31,8 +31,7 @@ async def cloud_rm(
return f"Error: refusing to rm '{validated}'."
if not validated.startswith(DOCUMENTS_ROOT + "/"):
return (
"Error: cloud rm must target a path under /documents/ "
f"(got '{validated}')."
f"Error: cloud rm must target a path under /documents/ (got '{validated}')."
)
anon = runtime.state.get("kb_anon_doc") or {}
@ -41,14 +40,10 @@ async def cloud_rm(
staged_dirs = list(runtime.state.get("staged_dirs") or [])
if validated in staged_dirs:
return (
f"Error: '{validated}' is a directory. Use rmdir for "
"empty directories."
)
return f"Error: '{validated}' is a directory. Use rmdir for empty directories."
pending_dir_deletes = list(runtime.state.get("pending_dir_deletes") or [])
if any(
isinstance(d, dict) and d.get("path") == validated
for d in pending_dir_deletes
isinstance(d, dict) and d.get("path") == validated for d in pending_dir_deletes
):
return f"Error: '{validated}' is already queued for rmdir."
@ -57,14 +52,11 @@ async def cloud_rm(
children = await backend.als_info(validated)
if children:
return (
f"Error: '{validated}' is a directory. Use rmdir for "
"empty directories."
f"Error: '{validated}' is a directory. Use rmdir for empty directories."
)
pending_deletes = list(runtime.state.get("pending_deletes") or [])
if any(
isinstance(d, dict) and d.get("path") == validated for d in pending_deletes
):
if any(isinstance(d, dict) and d.get("path") == validated for d in pending_deletes):
return f"'{validated}' is already queued for deletion."
files_state = runtime.state.get("files") or {}
@ -93,8 +85,7 @@ async def cloud_rm(
"messages": [
ToolMessage(
content=(
f"Staged delete of '{validated}' (will commit at "
"end of turn)."
f"Staged delete of '{validated}' (will commit at end of turn)."
),
tool_call_id=runtime.tool_call_id,
)
@ -114,7 +105,7 @@ async def cloud_rm(
async def desktop_rm(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
validated: str,
) -> Command | str:

View file

@ -21,7 +21,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_rm_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_rm_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_rm(

View file

@ -26,7 +26,7 @@ if TYPE_CHECKING:
async def cloud_rmdir(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
validated: str,
) -> Command | str:
@ -49,8 +49,7 @@ async def cloud_rmdir(
staged_dirs = list(runtime.state.get("staged_dirs") or [])
pending_dir_deletes = list(runtime.state.get("pending_dir_deletes") or [])
if any(
isinstance(d, dict) and d.get("path") == validated
for d in pending_dir_deletes
isinstance(d, dict) and d.get("path") == validated for d in pending_dir_deletes
):
return f"'{validated}' is already queued for deletion."
@ -61,11 +60,7 @@ async def cloud_rmdir(
if isinstance(backend, KBPostgresBackend):
children = list(await backend.als_info(validated))
if (
isinstance(backend, KBPostgresBackend)
and not children
and not exists_in_staged
):
if isinstance(backend, KBPostgresBackend) and not children and not exists_in_staged:
loaded = await backend._load_file_data(validated)
if loaded is not None:
return f"Error: '{validated}' is a file. Use rm to delete files."
@ -79,9 +74,7 @@ async def cloud_rmdir(
return f"Error: directory '{validated}' not found."
if children:
return (
f"Error: directory '{validated}' is not empty. Remove contents first."
)
return f"Error: directory '{validated}' is not empty. Remove contents first."
if exists_in_staged:
rest = [d for d in staged_dirs if d != validated]
@ -109,8 +102,7 @@ async def cloud_rmdir(
"messages": [
ToolMessage(
content=(
f"Staged rmdir of '{validated}' (will commit "
"at end of turn)."
f"Staged rmdir of '{validated}' (will commit at end of turn)."
),
tool_call_id=runtime.tool_call_id,
)
@ -120,7 +112,7 @@ async def cloud_rmdir(
async def desktop_rmdir(
mw: "SurfSenseFilesystemMiddleware",
mw: SurfSenseFilesystemMiddleware,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
validated: str,
) -> Command | str:

View file

@ -21,7 +21,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_rmdir_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_rmdir_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_rmdir(

View file

@ -23,7 +23,7 @@ if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
def create_write_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
def create_write_file_tool(mw: SurfSenseFilesystemMiddleware) -> BaseTool:
description = select_description(mw._filesystem_mode)
async def async_write_file(
@ -73,9 +73,7 @@ def create_write_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
content: Annotated[str, "Text content to write to the file."],
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> Command | str:
return run_async_blocking(
async_write_file(file_path, content, runtime)
)
return run_async_blocking(async_write_file(file_path, content, runtime))
return StructuredTool.from_function(
name="write_file",

View file

@ -1,16 +1,11 @@
"""Pattern-based allow/deny/ask middleware with HITL fallback.
"""Pattern-based allow/deny/ask middleware with HITL fallback (vertical slice).
Public surface: :class:`PermissionMiddleware` plus
:func:`normalize_permission_decision` for the streaming layer and the
:data:`PatternResolver` type for callers that register per-tool resolvers.
Public surface (one entry point only every other symbol is an internal of
the rule engine and stays inside ``middleware/``, ``ask/``, or ``deny.py``):
- :func:`build_permission_mw` construction recipe shared by every stack.
"""
from .decision import normalize_permission_decision
from .middleware import PermissionMiddleware
from .pattern_resolver import PatternResolver
from .middleware.factory import build_permission_mw
__all__ = [
"PatternResolver",
"PermissionMiddleware",
"normalize_permission_decision",
]
__all__ = ["build_permission_mw"]

View file

@ -0,0 +1,74 @@
"""Translate the unified langchain HITL envelope into permission-domain semantics.
``PermissionMiddleware`` works with the canonical shape
``{decision_type: "once" | "approve_always" | "reject", feedback?: str, edited_args?: dict}``.
The wire envelope arriving from langgraph already lives in the LC HITL shape
(parsed once in :mod:`hitl_wire.decision`); this module performs the small
domain mapping (``approve|edit`` ``once``, ``approve_always``
``approve_always``, anything else ``reject``) without re-implementing the
envelope walk.
Failing closed: any unrecognised decision becomes ``reject`` (with a warning)
so the middleware never proceeds on ambiguous input.
"""
from __future__ import annotations
import logging
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
LC_DECISION_APPROVE,
LC_DECISION_EDIT,
LC_DECISION_REJECT,
SURFSENSE_DECISION_APPROVE_ALWAYS,
parse_lc_envelope,
)
logger = logging.getLogger(__name__)
# ``approve`` and ``edit`` both mean "let this call go through this once". The
# legacy SurfSense bare-scalar values (``once`` / ``approve_always`` / ``reject``)
# pass through unchanged so historical resume payloads still work.
_LC_TO_PERMISSION: dict[str, str] = {
LC_DECISION_APPROVE: "once",
LC_DECISION_EDIT: "once",
SURFSENSE_DECISION_APPROVE_ALWAYS: "approve_always",
LC_DECISION_REJECT: "reject",
"once": "once",
"approve_always": "approve_always",
"reject": "reject",
}
def normalize_permission_decision(envelope: Any) -> dict[str, Any]:
"""Project the user's reply into the canonical permission decision shape.
Args:
envelope: The raw resume value from langgraph (LC HITL envelope, a
bare scalar string, or a pre-canonical dict).
Returns:
``{"decision_type": "once"|"approve_always"|"reject"}`` plus optional
``feedback`` (``reject`` with a user message) and ``edited_args``
(``edit`` reply with non-empty arg overrides).
"""
parsed = parse_lc_envelope(envelope)
mapped = _LC_TO_PERMISSION.get(parsed.decision_type)
if mapped is None:
logger.warning(
"Unknown permission decision %r; treating as reject",
parsed.decision_type,
)
mapped = "reject"
out: dict[str, Any] = {"decision_type": mapped}
if parsed.message:
out["feedback"] = parsed.message
if parsed.edited_args:
out["edited_args"] = parsed.edited_args
return out
__all__ = ["normalize_permission_decision"]

View file

@ -0,0 +1,10 @@
"""Apply ``edit`` permission decisions to tool calls.
Edited-arg extraction now lives in :mod:`hitl_wire.decision` (single parser
for all approval paths); this module owns the merge step that produces a
fresh tool-call dict for the orchestrator.
"""
from .merge import merge_edited_args
__all__ = ["merge_edited_args"]

View file

@ -0,0 +1,22 @@
"""Apply edited args to a tool call (shallow merge, no mutation).
Edited values override originals; keys absent from ``edited_args`` keep
their original values, so partial edits are safe. Returns a NEW tool-call
dict so the caller can swap it into ``AIMessage.tool_calls`` without
aliasing the live message object.
"""
from __future__ import annotations
from typing import Any
def merge_edited_args(
tool_call: dict[str, Any], edited_args: dict[str, Any]
) -> dict[str, Any]:
original_args = tool_call.get("args") or {}
merged_args = {**original_args, **edited_args}
return {**tool_call, "args": merged_args}
__all__ = ["merge_edited_args"]

View file

@ -0,0 +1,89 @@
"""Build the permission-ask interrupt payload (LC HITL wire + SurfSense context)."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
LC_DECISION_APPROVE,
LC_DECISION_EDIT,
LC_DECISION_REJECT,
SURFSENSE_DECISION_APPROVE_ALWAYS,
build_lc_hitl_payload,
)
from app.agents.new_chat.permissions import Rule
PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask"
_BASE_PERMISSION_ASK_DECISIONS: list[str] = [
LC_DECISION_APPROVE,
LC_DECISION_REJECT,
LC_DECISION_EDIT,
]
def _is_mcp_tool(tool: BaseTool | None) -> bool:
"""An MCP tool advertises a connector id in its langchain metadata."""
if tool is None:
return False
metadata = getattr(tool, "metadata", None) or {}
return metadata.get("mcp_connector_id") is not None
def _card_fields_from_tool(tool: BaseTool | None) -> dict[str, Any]:
"""Project the FE card's tool-scoped fields out of a BaseTool."""
if tool is None:
return {}
metadata = getattr(tool, "metadata", None) or {}
fields: dict[str, Any] = {}
connector_id = metadata.get("mcp_connector_id")
if connector_id is not None:
fields["mcp_connector_id"] = connector_id
connector_name = metadata.get("mcp_connector_name")
if connector_name:
fields["mcp_server"] = connector_name
if tool.description:
fields["tool_description"] = tool.description
return fields
def build_permission_ask_payload(
*,
tool_name: str,
args: dict[str, Any],
patterns: list[str],
rules: list[Rule],
tool: BaseTool | None = None,
) -> dict[str, Any]:
"""Build the permission-ask interrupt payload.
``approve_always`` is added to the palette only for MCP tools, since that
is the only case where the user's choice can persist beyond the current
agent instance (saved to the connector's trusted-tools list). Native
tools fall back to the once/reject/edit triad.
"""
allowed_decisions = list(_BASE_PERMISSION_ASK_DECISIONS)
if _is_mcp_tool(tool):
allowed_decisions.append(SURFSENSE_DECISION_APPROVE_ALWAYS)
context: dict[str, Any] = {
"patterns": patterns,
"rules": [
{"permission": r.permission, "pattern": r.pattern, "action": r.action}
for r in rules
],
"always": patterns,
**_card_fields_from_tool(tool),
}
return build_lc_hitl_payload(
tool_name=tool_name,
args=args,
allowed_decisions=allowed_decisions,
interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE,
context=context,
)
__all__ = ["PERMISSION_ASK_INTERRUPT_TYPE", "build_permission_ask_payload"]

View file

@ -0,0 +1,59 @@
"""Side-effectful entry point: pause the graph and return the permission decision.
Wraps :func:`langgraph.types.interrupt` with the OTel spans the SurfSense
dashboard expects, then projects the resume value through
:func:`normalize_permission_decision` so the middleware downstream only
sees the canonical permission-domain shape.
When ``emit_interrupt`` is ``False`` the call short-circuits to ``reject``;
this is used by non-interactive deployments where ``ask`` must not block.
"""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from langgraph.types import interrupt
from app.agents.new_chat.permissions import Rule
from app.observability import otel as ot
from .decision import normalize_permission_decision
from .payload import PERMISSION_ASK_INTERRUPT_TYPE, build_permission_ask_payload
def request_permission_decision(
*,
tool_name: str,
args: dict[str, Any],
patterns: list[str],
rules: list[Rule],
emit_interrupt: bool,
tool: BaseTool | None = None,
) -> dict[str, Any]:
"""Pause for an ``ask`` decision; return the canonical permission decision dict."""
if not emit_interrupt:
return {"decision_type": "reject"}
payload = build_permission_ask_payload(
tool_name=tool_name,
args=args,
patterns=patterns,
rules=rules,
tool=tool,
)
with (
ot.permission_asked_span(
permission=tool_name,
pattern=patterns[0] if patterns else None,
extra={"permission.patterns": list(patterns)},
),
ot.interrupt_span(interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE),
):
decision = interrupt(payload)
return normalize_permission_decision(decision)
__all__ = ["request_permission_decision"]

View file

@ -1,91 +0,0 @@
"""Coerce inbound permission decisions to a canonical dict shape.
Two wire formats are accepted:
- SurfSense legacy: ``{"decision_type": "once"|"always"|"reject", "feedback"?}``.
- LangChain HITL envelope: ``{"decisions": [{"type": "approve"|"edit"|"reject", ...}]}``.
The middleware downstream only inspects the canonical shape returned here,
so adding a new envelope means changing this module alone.
The middleware fails closed: any unrecognised payload becomes ``reject``
(with a warning) so the agent never proceeds on ambiguous input.
When the reply is an ``edit``, the result keeps ``decision_type="once"``
(the call still goes through) and adds an ``edited_args`` key holding the
user-modified ``args`` dict. The orchestrator merges those into the
``tool_call`` before keeping it; see :mod:`interrupt.edit.merge`.
"""
from __future__ import annotations
import logging
from typing import Any
from .interrupt.edit import extract_edited_args
logger = logging.getLogger(__name__)
# ``edit`` collapses to ``once``; any ``edited_args`` ride on the result.
_LC_TYPE_TO_PERMISSION_DECISION: dict[str, str] = {
"approve": "once",
"reject": "reject",
"edit": "once",
}
def normalize_permission_decision(decision: Any) -> dict[str, Any]:
"""Return ``{"decision_type": ..., "feedback"?: str, "edited_args"?: dict}``."""
if isinstance(decision, str):
return {"decision_type": decision}
if not isinstance(decision, dict):
logger.warning(
"Unrecognized permission resume value (%s); treating as reject",
type(decision).__name__,
)
return {"decision_type": "reject"}
if decision.get("decision_type"):
return decision
payload: dict[str, Any] = decision
decisions = decision.get("decisions")
if isinstance(decisions, list) and decisions:
first = decisions[0]
if isinstance(first, dict):
payload = first
raw_type = payload.get("type") or payload.get("decision_type")
if not raw_type:
logger.warning(
"Permission resume missing decision type (keys=%s); treating as reject",
list(payload.keys()),
)
return {"decision_type": "reject"}
raw_type = str(raw_type).lower()
mapped = _LC_TYPE_TO_PERMISSION_DECISION.get(raw_type)
if mapped is None:
# Tolerate legacy values arriving without ``decision_type`` wrapping.
if raw_type in {"once", "always", "reject"}:
mapped = raw_type
else:
logger.warning(
"Unknown permission decision type %r; treating as reject", raw_type
)
mapped = "reject"
out: dict[str, Any] = {"decision_type": mapped}
feedback = payload.get("feedback") or payload.get("message")
if isinstance(feedback, str) and feedback.strip():
out["feedback"] = feedback
if raw_type == "edit":
edited = extract_edited_args(payload)
if edited:
out["edited_args"] = edited
return out
__all__ = ["normalize_permission_decision"]

View file

@ -1,6 +0,0 @@
"""Apply ``edit`` permission decisions to tool calls (extract + merge)."""
from .extract import extract_edited_args
from .merge import merge_edited_args
__all__ = ["extract_edited_args", "merge_edited_args"]

View file

@ -1,34 +0,0 @@
"""Extract edited args from a permission decision payload.
Two shapes are accepted (mirrors :func:`app.agents.new_chat.tools.hitl._parse_decision`):
- LangChain HITL envelope: ``{"edited_action": {"args": {...}}}``.
- Legacy flat shape: ``{"args": {...}}``.
Returns ``None`` when no edited args are present. The orchestrator decides
whether to merge them (see :mod:`interrupt.edit.merge`); this module is pure parsing.
"""
from __future__ import annotations
from typing import Any
def extract_edited_args(decision_payload: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(decision_payload, dict):
return None
edited_action = decision_payload.get("edited_action")
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
return edited_args
flat_args = decision_payload.get("args")
if isinstance(flat_args, dict):
return flat_args
return None
__all__ = ["extract_edited_args"]

View file

@ -1,25 +0,0 @@
"""Apply edited args to a tool call.
Semantics match :func:`app.agents.new_chat.tools.hitl.request_approval`'s
``final_params = {**params, **edited_params}`` shallow merge, edited
values override originals. Keys absent from ``edited_args`` keep their
original values, so partial edits are safe.
Returns a NEW ``tool_call`` dict (the input is not mutated) so the caller
can swap it into the ``AIMessage.tool_calls`` list without aliasing.
"""
from __future__ import annotations
from typing import Any
def merge_edited_args(
tool_call: dict[str, Any], edited_args: dict[str, Any]
) -> dict[str, Any]:
original_args = tool_call.get("args") or {}
merged_args = {**original_args, **edited_args}
return {**tool_call, "args": merged_args}
__all__ = ["merge_edited_args"]

View file

@ -1,43 +0,0 @@
"""Build the ``permission_ask`` interrupt payload (pure data).
The frontend's streaming layer keys off ``type`` and renders the approval
card from ``action`` (the tool call being reviewed) and ``context``
(the matched rules and patterns that prompted the ask). ``context.always``
lists the patterns the user can promote to a permanent allow rule with a
single ``"always"`` reply.
"""
from __future__ import annotations
from typing import Any
from app.agents.new_chat.permissions import Rule
def build_permission_ask_payload(
*,
tool_name: str,
args: dict[str, Any],
patterns: list[str],
rules: list[Rule],
) -> dict[str, Any]:
return {
"type": "permission_ask",
# ``params`` (not ``args``) is what SurfSense's streaming normalizer forwards.
"action": {"tool": tool_name, "params": args or {}},
"context": {
"patterns": patterns,
"rules": [
{
"permission": r.permission,
"pattern": r.pattern,
"action": r.action,
}
for r in rules
],
"always": patterns,
},
}
__all__ = ["build_permission_ask_payload"]

View file

@ -1,52 +0,0 @@
"""Request a permission decision from the user (side-effectful entry point).
Wraps :func:`langgraph.types.interrupt` with the OTel spans that the
SurfSense dashboard expects, then normalises the resume value through
:func:`decision.normalize_permission_decision`.
When ``emit_interrupt`` is ``False`` the call short-circuits to
``reject``; this is used by non-interactive deployments where ``ask`` must
not block.
"""
from __future__ import annotations
from typing import Any
from langgraph.types import interrupt
from app.agents.new_chat.permissions import Rule
from app.observability import otel as ot
from ..decision import normalize_permission_decision
from .payload import build_permission_ask_payload
def request_permission_decision(
*,
tool_name: str,
args: dict[str, Any],
patterns: list[str],
rules: list[Rule],
emit_interrupt: bool,
) -> dict[str, Any]:
if not emit_interrupt:
return {"decision_type": "reject"}
payload = build_permission_ask_payload(
tool_name=tool_name, args=args, patterns=patterns, rules=rules
)
with (
ot.permission_asked_span(
permission=tool_name,
pattern=patterns[0] if patterns else None,
extra={"permission.patterns": list(patterns)},
),
ot.interrupt_span(interrupt_type="permission_ask"),
):
decision = interrupt(payload)
return normalize_permission_decision(decision)
__all__ = ["request_permission_decision"]

View file

@ -5,35 +5,16 @@ LangChain's :class:`HumanInTheLoopMiddleware` only supports a static
allow/deny/ask, no glob patterns, no per-space/per-thread overrides, and
no auto-deny synthesis.
This middleware layers OpenCode's wildcard-ruleset model on top of
SurfSense's ``interrupt({type, action, context})`` payload shape (see
:mod:`app.agents.new_chat.tools.hitl`) so the frontend keeps working
unchanged.
Per-tool-call flow inside :meth:`_process`:
1. Skip when the last message has no tool calls.
2. For each call, evaluate the rules. ``deny`` is replaced with a
synthetic :class:`ToolMessage` carrying a typed
:class:`StreamingError`. ``ask`` raises an interrupt via
:mod:`interrupt.request`; the resulting decision is dispatched here:
- ``once`` keep the call as-is.
- ``always`` also extend the runtime ruleset.
- ``reject`` (with feedback) :class:`CorrectedError`.
- ``reject`` (no feedback) :class:`RejectedError`.
``allow`` keeps the call unchanged.
3. Returns an updated ``AIMessage`` (tool calls minus the denied ones)
plus any deny ``ToolMessage`` entries appended after it. Tool-list
filtering at ``before_model`` is intentionally not done here that
would invalidate provider prompt-cache prefixes.
This middleware layers OpenCode's wildcard-ruleset model on top of the
unified langchain HITL wire format (see :mod:`hitl_wire`), so it sits
beside ``HumanInTheLoopMiddleware`` and self-gated approvals on a single
parallel-HITL routing layer in ``task_tool`` + ``resume_routing``.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from langchain.agents.middleware.types import (
@ -42,22 +23,32 @@ from langchain.agents.middleware.types import (
ContextT,
)
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import BaseTool
from langgraph.runtime import Runtime
from app.agents.new_chat.errors import CorrectedError, RejectedError
from app.agents.new_chat.permissions import Ruleset
from app.services.user_tool_allowlist import TrustedToolSaver
from ..ask.edit import merge_edited_args
from ..ask.request import request_permission_decision
from ..deny import build_deny_message
from ..interrupt.edit import merge_edited_args
from ..interrupt.request import request_permission_decision
from ..pattern_resolver import PatternResolver
from ..runtime_promote import persist_always
from .evaluation import evaluate_tool_call
from .pattern_resolver import PatternResolver
from .ruleset_view import all_rulesets
from .runtime_promote import persist_always
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _AlwaysPromotion:
"""A pending request to save an ``approve_always`` decision to the user's trust list."""
connector_id: int
tool_name: str
class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
"""Allow/deny/ask layer over the agent's tool calls.
@ -68,10 +59,17 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
to wildcard patterns. Tools without an entry use the bare
tool name as the only pattern.
runtime_ruleset: Mutable :class:`Ruleset` extended in-place when
the user replies ``"always"``. Reused across calls in the
same agent instance so newly-allowed rules apply downstream.
the user replies ``"approve_always"``. Reused across calls in
the same agent instance so newly-allowed rules apply downstream.
always_emit_interrupt_payload: Set ``False`` to make ``ask``
collapse to ``deny`` (for non-interactive deployments).
tools_by_name: Map from tool name to :class:`BaseTool`, used to
decorate ``ask`` interrupts with the tool's description and
MCP metadata for the FE card.
trusted_tool_saver: Async callback invoked on ``approve_always``
decisions for MCP tools (those whose ``metadata`` carries an
``mcp_connector_id``). Without it the promotion only lives
in-memory for the current agent instance.
"""
tools = ()
@ -83,6 +81,8 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
pattern_resolvers: dict[str, PatternResolver] | None = None,
runtime_ruleset: Ruleset | None = None,
always_emit_interrupt_payload: bool = True,
tools_by_name: dict[str, BaseTool] | None = None,
trusted_tool_saver: TrustedToolSaver | None = None,
) -> None:
super().__init__()
self._static_rulesets: list[Ruleset] = list(rulesets or [])
@ -93,23 +93,33 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
origin="runtime_approved"
)
self._emit_interrupt = always_emit_interrupt_payload
self._tools_by_name: dict[str, BaseTool] = dict(tools_by_name or {})
self._trusted_tool_saver: TrustedToolSaver | None = trusted_tool_saver
def _process(
self,
state: AgentState,
runtime: Runtime[Any],
) -> dict[str, Any] | None:
) -> tuple[dict[str, Any] | None, list[_AlwaysPromotion]]:
"""Pure decision pass: returns ``(state_update, pending_promotions)``.
Side effects performed here are in-memory only (rule promotion
into ``runtime_ruleset``). DB writes for ``approve_always``
decisions are queued as ``_AlwaysPromotion`` and flushed by the
async hook.
"""
del runtime
messages = state.get("messages") or []
if not messages:
return None
return None, []
last = messages[-1]
if not isinstance(last, AIMessage) or not last.tool_calls:
return None
return None, []
rulesets = all_rulesets(self._static_rulesets, self._runtime_ruleset)
deny_messages: list[ToolMessage] = []
kept_calls: list[dict[str, Any]] = []
promotions: list[_AlwaysPromotion] = []
any_change = False
for raw in last.tool_calls:
@ -142,10 +152,11 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
patterns=patterns,
rules=rules,
emit_interrupt=self._emit_interrupt,
tool=self._tools_by_name.get(name),
)
kind = str(decision.get("decision_type") or "reject").lower()
edited_args = decision.get("edited_args")
if kind in ("once", "always"):
if kind in ("once", "approve_always"):
final_call = (
merge_edited_args(call, edited_args)
if isinstance(edited_args, dict) and edited_args
@ -153,8 +164,11 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
)
if final_call is not call:
any_change = True
if kind == "always":
if kind == "approve_always":
persist_always(self._runtime_ruleset, name, patterns)
promotion = self._build_always_promotion(name)
if promotion is not None:
promotions.append(promotion)
kept_calls.append(final_call)
elif kind == "reject":
feedback = decision.get("feedback")
@ -173,23 +187,39 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
kept_calls.append(call)
if not any_change and len(kept_calls) == len(last.tool_calls):
return None
return None, promotions
updated = last.model_copy(update={"tool_calls": kept_calls})
result_messages: list[Any] = [updated]
if deny_messages:
result_messages.extend(deny_messages)
return {"messages": result_messages}
return {"messages": result_messages}, promotions
def _build_always_promotion(self, tool_name: str) -> _AlwaysPromotion | None:
"""Return a save request iff the tool exposes an ``mcp_connector_id``."""
tool = self._tools_by_name.get(tool_name)
metadata = getattr(tool, "metadata", None) or {}
connector_id = metadata.get("mcp_connector_id")
if not isinstance(connector_id, int):
return None
return _AlwaysPromotion(connector_id=connector_id, tool_name=tool_name)
def after_model( # type: ignore[override]
self, state: AgentState, runtime: Runtime[ContextT]
) -> dict[str, Any] | None:
return self._process(state, runtime)
update, _ = self._process(state, runtime)
return update
async def aafter_model( # type: ignore[override]
self, state: AgentState, runtime: Runtime[ContextT]
) -> dict[str, Any] | None:
return self._process(state, runtime)
update, promotions = self._process(state, runtime)
if self._trusted_tool_saver is not None:
for promotion in promotions:
await self._trusted_tool_saver(
promotion.connector_id, promotion.tool_name
)
return update
__all__ = ["PermissionMiddleware"]

View file

@ -24,7 +24,7 @@ from app.agents.new_chat.permissions import (
evaluate_many,
)
from ..pattern_resolver import PatternResolver, default_pattern_resolver
from .pattern_resolver import PatternResolver, default_pattern_resolver
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,88 @@
"""Construction recipe for :class:`PermissionMiddleware` shared across stacks.
Single source of truth used by both the main-agent stack and every subagent
stack. Rule layers are evaluated earliest-to-latest (last match wins,
matching OpenCode's ``permission/index.ts`` evaluation order):
1. ``surfsense_defaults`` single ``allow */*`` rule. Connector tools
already self-gate via :func:`request_approval`, so the rule engine only
needs to *deny* what the user has explicitly forbidden; the default
``ask`` fallback would otherwise double-prompt every safe read-only
call.
2. ``subagent_rulesets`` caller-supplied rulesets contributed by the
consuming subagent. Each subagent passes its coded rules (KB:
destructive-FS ``ask`` rules; connectors: per-tool ``allow``/``ask``)
plus, when present, the user's persisted allow-list for that subagent.
Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules``
is intentionally NOT replicated: the multi-agent orchestrator already
excludes entire subagents whose required connectors are missing
(``SUBAGENT_TO_REQUIRED_CONNECTOR_MAP``), so the per-tool deny pass is
redundant here.
"""
from __future__ import annotations
from collections.abc import Sequence
from langchain_core.tools import BaseTool
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Rule, Ruleset
from app.services.user_tool_allowlist import TrustedToolSaver
from .core import PermissionMiddleware
_SURFSENSE_DEFAULTS = Ruleset(
rules=[Rule(permission="*", pattern="*", action="allow")],
origin="surfsense_defaults",
)
def build_permission_mw(
*,
flags: AgentFeatureFlags,
subagent_rulesets: list[Ruleset] | None = None,
tools: Sequence[BaseTool] | None = None,
trusted_tool_saver: TrustedToolSaver | None = None,
) -> PermissionMiddleware | None:
"""Return a configured :class:`PermissionMiddleware` or ``None`` when no work is needed.
Args:
flags: Feature toggles. ``enable_permission`` switches the engine on;
``disable_new_agent_stack`` overrides everything for safety.
subagent_rulesets: Caller-supplied rulesets layered after the
defaults. Subagents pass their own coded ruleset here (and,
when present, the user's persisted allow-list for that
subagent) so each subagent owns its own rule surface without
aliasing a shared engine. Presence of any subagent ruleset
forces the middleware on regardless of ``enable_permission``
an explicit ``ask`` rule always asks.
tools: Subagent tools used to decorate ``ask`` interrupts with
FE-card metadata (description, MCP connector). Optional.
trusted_tool_saver: Async callback invoked when an MCP tool's
``always`` decision lands; persists the user's preference to
``connector.config['trusted_tools']``. Optional.
Returns:
``None`` when the engine has no rules to enforce
(``enable_permission=False`` and no subagent rulesets); a
configured middleware otherwise.
"""
permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack
has_subagent_rulesets = bool(subagent_rulesets)
if not (permission_enabled or has_subagent_rulesets):
return None
rulesets: list[Ruleset] = [_SURFSENSE_DEFAULTS]
if subagent_rulesets:
rulesets.extend(subagent_rulesets)
tools_by_name = {t.name: t for t in (tools or [])}
return PermissionMiddleware(
rulesets=rulesets,
tools_by_name=tools_by_name,
trusted_tool_saver=trusted_tool_saver,
)
__all__ = ["build_permission_mw"]

View file

@ -3,8 +3,8 @@
Static rulesets come from the agent factory (defaults, space-scoped,
thread-scoped, etc.). The runtime ruleset is the in-memory one that
:func:`runtime_promote.persist_always` extends when the user replies
``"always"``. Evaluators always see them merged in this order so newly-
promoted rules apply to subsequent calls.
``"approve_always"``. Evaluators always see them merged in this order so
newly-promoted rules apply to subsequent calls.
"""
from __future__ import annotations

View file

@ -1,4 +1,4 @@
"""Promote an ``"always"`` reply into in-memory allow rules.
"""Promote an ``"approve_always"`` reply into in-memory allow rules.
Subsequent calls within the same agent instance match these new rules and
proceed without prompting. Durable persistence (to ``agent_permission_rules``)

View file

@ -31,7 +31,6 @@ from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.agent import
from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.ask_knowledge_base_tool import (
build_ask_knowledge_base_tool,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.db import ChatVisibility
@ -61,6 +60,7 @@ from .shared.compaction import build_compaction_mw
from .shared.kb_context_projection import build_kb_context_projection_mw
from .shared.memory import build_memory_mw
from .shared.patch_tool_calls import build_patch_tool_calls_mw
from .shared.permissions import build_permission_mw
from .shared.resilience import build_resilience_middlewares
from .shared.todos import build_todos_mw
from .subagent.middleware_stack import build_subagent_middleware_stack
@ -84,7 +84,7 @@ def build_main_agent_deepagent_middleware(
flags: AgentFeatureFlags,
subagent_dependencies: dict[str, Any],
checkpointer: Checkpointer,
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,
) -> list[Any]:
"""Ordered middleware for ``create_agent`` (None entries already stripped)."""
@ -100,14 +100,19 @@ def build_main_agent_deepagent_middleware(
**subagent_dependencies,
"backend_resolver": backend_resolver,
"filesystem_mode": filesystem_mode,
"flags": flags,
}
shared_subagent_middleware = build_subagent_middleware_stack(resilience=resilience)
shared_subagent_middleware = build_subagent_middleware_stack(
resilience=resilience,
flags=flags,
)
kb_readonly_spec = build_kb_readonly_subagent(
kb_readonly = build_kb_readonly_subagent(
dependencies=subagent_dependencies,
model=llm,
middleware_stack=shared_subagent_middleware,
)
kb_readonly_spec = kb_readonly.spec
kb_readonly_runnable = create_agent(
llm,
system_prompt=kb_readonly_spec["system_prompt"],
@ -182,6 +187,7 @@ def build_main_agent_deepagent_middleware(
resilience.retry,
resilience.fallback,
build_repair_mw(flags=flags, tools=tools),
build_permission_mw(flags=flags),
build_doom_loop_mw(flags),
build_action_log_mw(
flags=flags,

View file

@ -3,7 +3,8 @@
Mirrors ``middleware/stack.py`` (the orchestrator's middleware stack) but
exposes its contents as a dict keyed by purpose so specialists can pick
the entries they need and decide ordering. The default consumer
(``pack_subagent``) prepends every non-``None`` value in insertion order.
(:func:`pack_subagent`) prepends every non-``None`` value in insertion
order, so ``None`` slots are silently skipped.
Registry subagents never touch the SurfSense filesystem that capability
belongs to ``knowledge_base`` so no FS middleware is exposed here.
@ -13,6 +14,9 @@ from __future__ import annotations
from typing import Any
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from ..shared.permissions import build_permission_mw
from ..shared.resilience import ResilienceMiddlewares
from ..shared.todos import build_todos_mw
@ -20,9 +24,24 @@ from ..shared.todos import build_todos_mw
def build_subagent_middleware_stack(
*,
resilience: ResilienceMiddlewares,
flags: AgentFeatureFlags | None = None,
) -> dict[str, Any]:
"""Assemble the dict of middlewares prepended to every subagent's stack.
Args:
resilience: Pre-built retry / fallback / call-limit middlewares
(shared with the orchestrator stack to keep behaviour symmetric).
flags: Feature flags driving optional layers. ``None`` disables the
permission layer (used in tests that only need todos+resilience).
Returns:
Insertion-ordered dict; ``None`` values are tolerated and dropped by
the consumer so callers can flip slots on/off without reshaping.
"""
permission = build_permission_mw(flags=flags) if flags is not None else None
return {
"todos": build_todos_mw(),
"permission": permission,
"retry": resilience.retry,
"fallback": resilience.fallback,
"model_call_limit": resilience.model_call_limit,

View file

@ -1,27 +1,22 @@
"""`deliverables` route: ``SubAgent`` spec for deepagents."""
"""``deliverables`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "deliverables"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles deliverables tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles deliverables tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,10 +1,15 @@
"""``deliverables`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .generate_image import create_generate_image_tool
from .podcast import create_generate_podcast_tool
@ -12,43 +17,39 @@ from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .video_presentation import create_generate_video_presentation_tool
NAME = "deliverables"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
podcast = create_generate_podcast_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
thread_id=resolved_dependencies["thread_id"],
)
video = create_generate_video_presentation_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
thread_id=resolved_dependencies["thread_id"],
)
report = create_generate_report_tool(
search_space_id=resolved_dependencies["search_space_id"],
thread_id=resolved_dependencies["thread_id"],
connector_service=resolved_dependencies.get("connector_service"),
available_connectors=resolved_dependencies.get("available_connectors"),
available_document_types=resolved_dependencies.get("available_document_types"),
)
resume = create_generate_resume_tool(
search_space_id=resolved_dependencies["search_space_id"],
thread_id=resolved_dependencies["thread_id"],
)
image = create_generate_image_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
)
return {
"allow": [
{"name": getattr(podcast, "name", "") or "", "tool": podcast},
{"name": getattr(video, "name", "") or "", "tool": video},
{"name": getattr(report, "name", "") or "", "tool": report},
{"name": getattr(resume, "name", "") or "", "tool": resume},
{"name": getattr(image, "name", "") or "", "tool": image},
],
"ask": [],
}
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return [
create_generate_podcast_tool(
search_space_id=d["search_space_id"],
db_session=d["db_session"],
thread_id=d["thread_id"],
),
create_generate_video_presentation_tool(
search_space_id=d["search_space_id"],
db_session=d["db_session"],
thread_id=d["thread_id"],
),
create_generate_report_tool(
search_space_id=d["search_space_id"],
thread_id=d["thread_id"],
connector_service=d.get("connector_service"),
available_connectors=d.get("available_connectors"),
available_document_types=d.get("available_document_types"),
),
create_generate_resume_tool(
search_space_id=d["search_space_id"],
thread_id=d["thread_id"],
),
create_generate_image_tool(
search_space_id=d["search_space_id"],
db_session=d["db_session"],
),
]

View file

@ -1,4 +1,9 @@
"""`knowledge_base` route: full and read-only ``SubAgent`` specs."""
"""``knowledge_base`` route: full and read-only ``SurfSenseSubagentSpec`` builders.
KB owns its destructive-FS approval ruleset (:data:`KB_RULESET`); rules
are layered into KB's :class:`PermissionMiddleware` (built inside
``build_kb_middleware``). One emitter, one wire format, one source of truth.
"""
from __future__ import annotations
@ -6,42 +11,56 @@ from typing import Any, cast
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.permissions import Rule, Ruleset
from .middleware_stack import build_kb_middleware
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
from .tools.index import destructive_fs_interrupt_on
from .tools.index import DESTRUCTIVE_FS_OPS
NAME = "knowledge_base"
READONLY_NAME = "knowledge_base_readonly"
KB_RULESET = Ruleset(
origin=NAME,
rules=[Rule(permission=op, pattern="*", action="ask") for op in DESTRUCTIVE_FS_OPS],
)
_KB_READONLY_RULESET = Ruleset(origin=READONLY_NAME, rules=[])
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None, # noqa: ARG001 — KB ships fixed tools
) -> SubAgent:
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
del mcp_tools
llm = model if model is not None else dependencies["llm"]
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
spec: dict[str, Any] = {
"name": NAME,
"description": load_description(),
"system_prompt": load_system_prompt(filesystem_mode),
"model": llm,
"tools": [],
"middleware": build_kb_middleware(
llm=llm,
dependencies=dependencies,
middleware_stack=middleware_stack,
read_only=False,
),
"interrupt_on": destructive_fs_interrupt_on(),
}
return cast(SubAgent, spec)
spec = cast(
SubAgent,
{
"name": NAME,
"description": load_description(),
"system_prompt": load_system_prompt(filesystem_mode),
"model": llm,
"tools": [],
"middleware": build_kb_middleware(
llm=llm,
dependencies=dependencies,
middleware_stack=middleware_stack,
read_only=False,
subagent_name=NAME,
ruleset=KB_RULESET,
),
},
)
return SurfSenseSubagentSpec(spec=spec, ruleset=KB_RULESET)
def build_readonly_subagent(
@ -49,21 +68,25 @@ def build_readonly_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
) -> SubAgent:
) -> SurfSenseSubagentSpec:
llm = model if model is not None else dependencies["llm"]
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
spec: dict[str, Any] = {
"name": READONLY_NAME,
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
"system_prompt": load_readonly_system_prompt(filesystem_mode),
"model": llm,
"tools": [],
"middleware": build_kb_middleware(
llm=llm,
dependencies=dependencies,
middleware_stack=middleware_stack,
read_only=True,
),
"interrupt_on": {},
}
return cast(SubAgent, spec)
spec = cast(
SubAgent,
{
"name": READONLY_NAME,
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
"system_prompt": load_readonly_system_prompt(filesystem_mode),
"model": llm,
"tools": [],
"middleware": build_kb_middleware(
llm=llm,
dependencies=dependencies,
middleware_stack=middleware_stack,
read_only=True,
subagent_name=READONLY_NAME,
ruleset=None,
),
},
)
return SurfSenseSubagentSpec(spec=spec, ruleset=_KB_READONLY_RULESET)

View file

@ -1,4 +1,8 @@
"""Middleware list shared by the full and read-only knowledge_base compiles."""
"""Middleware list shared by the full and read-only knowledge_base compiles.
The KB-owned :class:`PermissionMiddleware` slot is what enforces
"ask before destructive FS op" for KB tools.
"""
from __future__ import annotations
@ -21,7 +25,29 @@ from app.agents.multi_agent_chat.middleware.shared.kb_context_projection import
from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import (
build_patch_tool_calls_mw,
)
from app.agents.multi_agent_chat.middleware.shared.permissions import (
build_permission_mw,
)
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.permissions import Ruleset
def _kb_user_allowlist(
dependencies: dict[str, Any], subagent_name: str
) -> Ruleset | None:
"""Return the user's persisted allow-rules for ``subagent_name`` if any.
KB does not currently expose an "Always Allow" UI surface (the FE
button is MCP-only today), but the wiring is symmetrical with the
connector subagents so that adding KB trust later is a one-line
backend change.
"""
by_subagent = dependencies.get("user_allowlist_by_subagent") or {}
user_allowlist = by_subagent.get(subagent_name)
if isinstance(user_allowlist, Ruleset) and user_allowlist.rules:
return user_allowlist
return None
def build_kb_middleware(
@ -30,9 +56,27 @@ def build_kb_middleware(
dependencies: dict[str, Any],
middleware_stack: dict[str, Any] | None,
read_only: bool,
subagent_name: str,
ruleset: Ruleset | None = None,
) -> list[Any]:
"""Compose the KB subagent's middleware list.
Args:
subagent_name: Identity of the subagent being built (e.g.
``"knowledge_base"``, ``"knowledge_base_readonly"``). Used to
look up the user's persistent allow-list bucket in
``dependencies["user_allowlist_by_subagent"]``.
ruleset: The KB-owned permission ruleset (typically the
destructive-FS ``ask`` rules). When provided, a dedicated
:class:`PermissionMiddleware` is appended so KB enforces
approval at the rule layer. The user's persistent allow-list
for ``subagent_name`` is layered after ``ruleset`` so user
``allow`` rules override coded ``ask`` rules via
last-match-wins.
"""
mws = middleware_stack or {}
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
flags: AgentFeatureFlags | None = dependencies.get("flags")
resilience_mws = [
m
for m in (
@ -43,6 +87,17 @@ def build_kb_middleware(
)
if m is not None
]
permission_mw = None
if ruleset is not None and flags is not None:
rulesets: list[Ruleset] = [ruleset]
user_allowlist = _kb_user_allowlist(dependencies, subagent_name)
if user_allowlist is not None:
rulesets.append(user_allowlist)
permission_mw = build_permission_mw(
flags=flags,
subagent_rulesets=rulesets,
trusted_tool_saver=dependencies.get("trusted_tool_saver"),
)
return [
mws["todos"],
build_kb_context_projection_mw(),
@ -56,6 +111,7 @@ def build_kb_middleware(
),
build_compaction_mw(llm),
build_patch_tool_calls_mw(),
*([permission_mw] if permission_mw is not None else []),
*resilience_mws,
build_anthropic_cache_mw(),
]

View file

@ -1 +1 @@
"""Route-local tool policy for the ``knowledge_base`` subagent."""
"""Route-local tool permissions for the ``knowledge_base`` subagent."""

View file

@ -1,14 +1,9 @@
"""Route-local FS tool policy.
"""Route-local FS tool permissions.
The KB subagent's actual ``BaseTool`` instances are provided at runtime by
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module only
carries policy that the subagent spec needs to declare up front which
destructive ops require explicit user confirmation via ``interrupt_on``.
Mirrors the ``desktop_safety`` ruleset in
``multi_agent_chat.middleware.shared.permissions.context``: in desktop mode
those rules guard the main-agent FS toolset; in cloud mode the same toolset
lives on the KB subagent and the same policy is enforced here instead.
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module
only carries the *names* of destructive ops so the agent can convert them
into permission rules see :data:`KB_RULESET` in ``agent.py``.
"""
from __future__ import annotations
@ -22,9 +17,4 @@ DESTRUCTIVE_FS_OPS: tuple[str, ...] = (
)
def destructive_fs_interrupt_on() -> dict[str, bool]:
"""Fresh ``interrupt_on`` dict for the KB subagent spec."""
return {op: True for op in DESTRUCTIVE_FS_OPS}
__all__ = ["DESTRUCTIVE_FS_OPS", "destructive_fs_interrupt_on"]
__all__ = ["DESTRUCTIVE_FS_OPS"]

View file

@ -1,27 +1,17 @@
"""`memory` route: ``SubAgent`` spec for deepagents."""
"""``memory`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "memory"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +19,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles memory tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles memory tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,32 +1,37 @@
"""``memory`` native tools and (empty) permission ruleset."""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from app.db import ChatVisibility
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
NAME = "memory"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
if resolved_dependencies.get("thread_visibility") == ChatVisibility.SEARCH_SPACE:
mem = create_update_team_memory_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
llm=resolved_dependencies.get("llm"),
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
if d.get("thread_visibility") == ChatVisibility.SEARCH_SPACE:
return [
create_update_team_memory_tool(
search_space_id=d["search_space_id"],
db_session=d["db_session"],
llm=d.get("llm"),
)
]
return [
create_update_memory_tool(
user_id=d["user_id"],
db_session=d["db_session"],
llm=d.get("llm"),
)
return {
"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}],
"ask": [],
}
mem = create_update_memory_tool(
user_id=resolved_dependencies["user_id"],
db_session=resolved_dependencies["db_session"],
llm=resolved_dependencies.get("llm"),
)
return {"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}], "ask": []}
]

View file

@ -1,27 +1,17 @@
"""`research` route: ``SubAgent`` spec for deepagents."""
"""``research`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "research"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +19,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles research tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles research tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,35 +1,31 @@
"""``research`` native tools and (empty) permission ruleset."""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
from .web_search import create_web_search_tool
NAME = "research"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
web = create_web_search_tool(
search_space_id=resolved_dependencies.get("search_space_id"),
available_connectors=resolved_dependencies.get("available_connectors"),
)
scrape = create_scrape_webpage_tool(
firecrawl_api_key=resolved_dependencies.get("firecrawl_api_key")
)
docs = create_search_surfsense_docs_tool(
db_session=resolved_dependencies["db_session"]
)
return {
"allow": [
{"name": getattr(web, "name", "") or "", "tool": web},
{"name": getattr(scrape, "name", "") or "", "tool": scrape},
{"name": getattr(docs, "name", "") or "", "tool": docs},
],
"ask": [],
}
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return [
create_web_search_tool(
search_space_id=d.get("search_space_id"),
available_connectors=d.get("available_connectors"),
),
create_scrape_webpage_tool(firecrawl_api_key=d.get("firecrawl_api_key")),
create_search_surfsense_docs_tool(db_session=d["db_session"]),
]

View file

@ -1,27 +1,22 @@
"""`airtable` route: ``SubAgent`` spec for deepagents."""
"""``airtable`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools come exclusively from MCP. The connector's own approval ruleset is
declared in :data:`tools.index.RULESET`; the orchestrator layers it into
a per-subagent :class:`PermissionMiddleware`.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "airtable"
from .tools.index import NAME, RULESET
def build_subagent(
@ -29,26 +24,20 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles airtable tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
description = (
read_md_file(__package__, "description").strip()
or "Handles airtable tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
tools=list(mcp_tools or []),
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,14 +1,21 @@
"""``airtable`` permission ruleset (rules over MCP tool names)."""
from __future__ import annotations
from typing import Any
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
NAME = "airtable"
RULESET = Ruleset(
origin=NAME,
rules=[
Rule(permission="list_bases", pattern="*", action="allow"),
Rule(permission="search_bases", pattern="*", action="allow"),
Rule(permission="list_tables_for_base", pattern="*", action="allow"),
Rule(permission="get_table_schema", pattern="*", action="allow"),
Rule(permission="list_records_for_table", pattern="*", action="allow"),
Rule(permission="search_records", pattern="*", action="allow"),
Rule(permission="create_records_for_table", pattern="*", action="ask"),
Rule(permission="update_records_for_table", pattern="*", action="ask"),
],
)
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}

View file

@ -1,27 +1,22 @@
"""`calendar` route: ``SubAgent`` spec for deepagents."""
"""``calendar`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity with MCP-backed connectors.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "calendar"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles calendar tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles calendar tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -8,7 +8,9 @@ from googleapiclient.discovery import build
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -8,7 +8,9 @@ from googleapiclient.discovery import build
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -1,35 +1,39 @@
"""``calendar`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies, so the
ruleset just falls through to the SurfSense allow-by-default rules.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .create_event import create_create_calendar_event_tool
from .delete_event import create_delete_calendar_event_tool
from .search_events import create_search_calendar_events_tool
from .update_event import create_update_calendar_event_tool
NAME = "calendar"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
session_dependencies = {
"db_session": resolved_dependencies["db_session"],
"search_space_id": resolved_dependencies["search_space_id"],
"user_id": resolved_dependencies["user_id"],
}
search = create_search_calendar_events_tool(**session_dependencies)
create = create_create_calendar_event_tool(**session_dependencies)
update = create_update_calendar_event_tool(**session_dependencies)
delete = create_delete_calendar_event_tool(**session_dependencies)
return {
"allow": [{"name": getattr(search, "name", "") or "", "tool": search}],
"ask": [
{"name": getattr(create, "name", "") or "", "tool": create},
{"name": getattr(update, "name", "") or "", "tool": update},
{"name": getattr(delete, "name", "") or "", "tool": delete},
],
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
}
return [
create_search_calendar_events_tool(**common),
create_create_calendar_event_tool(**common),
create_update_calendar_event_tool(**common),
create_delete_calendar_event_tool(**common),
]

View file

@ -8,7 +8,9 @@ from googleapiclient.discovery import build
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -1,27 +1,22 @@
"""`clickup` route: ``SubAgent`` spec for deepagents."""
"""``clickup`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools come exclusively from MCP. The connector's own approval ruleset is
declared in :data:`tools.index.RULESET`; the orchestrator layers it into
a per-subagent :class:`PermissionMiddleware`.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "clickup"
from .tools.index import NAME, RULESET
def build_subagent(
@ -29,26 +24,20 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles clickup tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
description = (
read_md_file(__package__, "description").strip()
or "Handles clickup tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
tools=list(mcp_tools or []),
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,14 +1,20 @@
"""``clickup`` permission ruleset (rules over MCP tool names)."""
from __future__ import annotations
from typing import Any
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
NAME = "clickup"
RULESET = Ruleset(
origin=NAME,
rules=[
Rule(permission="clickup_search", pattern="*", action="allow"),
Rule(permission="clickup_get_task", pattern="*", action="allow"),
Rule(permission="clickup_get_workspace_hierarchy", pattern="*", action="allow"),
Rule(permission="clickup_get_list", pattern="*", action="allow"),
Rule(permission="clickup_find_member_by_name", pattern="*", action="allow"),
Rule(permission="clickup_create_task", pattern="*", action="ask"),
Rule(permission="clickup_update_task", pattern="*", action="ask"),
],
)
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}

View file

@ -1,27 +1,22 @@
"""`confluence` route: ``SubAgent`` spec for deepagents."""
"""``confluence`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "confluence"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles confluence tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles confluence tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -5,7 +5,9 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.confluence_history import ConfluenceHistoryConnector
from app.services.confluence import ConfluenceToolMetadataService

View file

@ -5,7 +5,9 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.confluence_history import ConfluenceHistoryConnector
from app.services.confluence import ConfluenceToolMetadataService

View file

@ -1,34 +1,37 @@
"""``confluence`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .create_page import create_create_confluence_page_tool
from .delete_page import create_delete_confluence_page_tool
from .update_page import create_update_confluence_page_tool
NAME = "confluence"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
session_dependencies = {
"db_session": resolved_dependencies["db_session"],
"search_space_id": resolved_dependencies["search_space_id"],
"user_id": resolved_dependencies["user_id"],
"connector_id": resolved_dependencies.get("connector_id"),
}
create = create_create_confluence_page_tool(**session_dependencies)
update = create_update_confluence_page_tool(**session_dependencies)
delete = create_delete_confluence_page_tool(**session_dependencies)
return {
"allow": [],
"ask": [
{"name": getattr(create, "name", "") or "", "tool": create},
{"name": getattr(update, "name", "") or "", "tool": update},
{"name": getattr(delete, "name", "") or "", "tool": delete},
],
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
"connector_id": d.get("connector_id"),
}
return [
create_create_confluence_page_tool(**common),
create_update_confluence_page_tool(**common),
create_delete_confluence_page_tool(**common),
]

View file

@ -5,7 +5,9 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.confluence_history import ConfluenceHistoryConnector
from app.services.confluence import ConfluenceToolMetadataService

View file

@ -1,27 +1,22 @@
"""`discord` route: ``SubAgent`` spec for deepagents."""
"""``discord`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "discord"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles discord tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles discord tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,32 +1,36 @@
"""``discord`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .list_channels import create_list_discord_channels_tool
from .read_messages import create_read_discord_messages_tool
from .send_message import create_send_discord_message_tool
NAME = "discord"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
}
list_ch = create_list_discord_channels_tool(**common)
read_msg = create_read_discord_messages_tool(**common)
send = create_send_discord_message_tool(**common)
return {
"allow": [
{"name": getattr(list_ch, "name", "") or "", "tool": list_ch},
{"name": getattr(read_msg, "name", "") or "", "tool": read_msg},
],
"ask": [{"name": getattr(send, "name", "") or "", "tool": send}],
}
return [
create_list_discord_channels_tool(**common),
create_read_discord_messages_tool(**common),
create_send_discord_message_tool(**common),
]

View file

@ -5,7 +5,9 @@ import httpx
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from ._auth import DISCORD_API, get_bot_token, get_discord_connector

View file

@ -1,27 +1,22 @@
"""`dropbox` route: ``SubAgent`` spec for deepagents."""
"""``dropbox`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "dropbox"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles dropbox tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles dropbox tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -8,7 +8,9 @@ from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.dropbox.client import DropboxClient
from app.db import SearchSourceConnector, SearchSourceConnectorType

View file

@ -1,30 +1,34 @@
"""``dropbox`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .create_file import create_create_dropbox_file_tool
from .trash_file import create_delete_dropbox_file_tool
NAME = "dropbox"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
}
create = create_create_dropbox_file_tool(**common)
delete = create_delete_dropbox_file_tool(**common)
return {
"allow": [],
"ask": [
{"name": getattr(create, "name", "") or "", "tool": create},
{"name": getattr(delete, "name", "") or "", "tool": delete},
],
}
return [
create_create_dropbox_file_tool(**common),
create_delete_dropbox_file_tool(**common),
]

View file

@ -6,7 +6,9 @@ from sqlalchemy import String, and_, cast, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.dropbox.client import DropboxClient
from app.db import (
Document,

View file

@ -1,27 +1,22 @@
"""`gmail` route: ``SubAgent`` spec for deepagents."""
"""``gmail`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "gmail"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles gmail tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles gmail tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -8,7 +8,9 @@ from typing import Any
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -1,10 +1,15 @@
"""``gmail`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .create_draft import create_create_gmail_draft_tool
from .read_email import create_read_gmail_email_tool
@ -13,31 +18,25 @@ from .send_email import create_send_gmail_email_tool
from .trash_email import create_trash_gmail_email_tool
from .update_draft import create_update_gmail_draft_tool
NAME = "gmail"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
}
search = create_search_gmail_tool(**common)
read = create_read_gmail_email_tool(**common)
draft = create_create_gmail_draft_tool(**common)
send = create_send_gmail_email_tool(**common)
trash = create_trash_gmail_email_tool(**common)
updraft = create_update_gmail_draft_tool(**common)
return {
"allow": [
{"name": getattr(search, "name", "") or "", "tool": search},
{"name": getattr(read, "name", "") or "", "tool": read},
],
"ask": [
{"name": getattr(draft, "name", "") or "", "tool": draft},
{"name": getattr(send, "name", "") or "", "tool": send},
{"name": getattr(trash, "name", "") or "", "tool": trash},
{"name": getattr(updraft, "name", "") or "", "tool": updraft},
],
}
return [
create_search_gmail_tool(**common),
create_read_gmail_email_tool(**common),
create_create_gmail_draft_tool(**common),
create_send_gmail_email_tool(**common),
create_trash_gmail_email_tool(**common),
create_update_gmail_draft_tool(**common),
]

View file

@ -8,7 +8,9 @@ from typing import Any
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -6,7 +6,9 @@ from typing import Any
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -8,7 +8,9 @@ from typing import Any
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)

View file

@ -1,27 +1,22 @@
"""`google_drive` route: ``SubAgent`` spec for deepagents."""
"""``google_drive`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "google_drive"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles google drive tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles google drive tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -5,7 +5,9 @@ from googleapiclient.errors import HttpError
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.google_drive.client import GoogleDriveClient
from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET
from app.services.google_drive import GoogleDriveToolMetadataService

View file

@ -1,30 +1,34 @@
"""``google_drive`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .create_file import create_create_google_drive_file_tool
from .trash_file import create_delete_google_drive_file_tool
NAME = "google_drive"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
}
create = create_create_google_drive_file_tool(**common)
delete = create_delete_google_drive_file_tool(**common)
return {
"allow": [],
"ask": [
{"name": getattr(create, "name", "") or "", "tool": create},
{"name": getattr(delete, "name", "") or "", "tool": delete},
],
}
return [
create_create_google_drive_file_tool(**common),
create_delete_google_drive_file_tool(**common),
]

View file

@ -5,7 +5,9 @@ from googleapiclient.errors import HttpError
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from app.connectors.google_drive.client import GoogleDriveClient
from app.services.google_drive import GoogleDriveToolMetadataService

View file

@ -1,27 +1,22 @@
"""`jira` route: ``SubAgent`` spec for deepagents."""
"""``jira`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools come exclusively from MCP. The connector's own approval ruleset is
declared in :data:`tools.index.RULESET`; the orchestrator layers it into
a per-subagent :class:`PermissionMiddleware`.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "jira"
from .tools.index import NAME, RULESET
def build_subagent(
@ -29,26 +24,20 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles jira tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
description = (
read_md_file(__package__, "description").strip()
or "Handles jira tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
tools=list(mcp_tools or []),
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,14 +1,24 @@
"""``jira`` permission ruleset (rules over MCP tool names)."""
from __future__ import annotations
from typing import Any
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
NAME = "jira"
RULESET = Ruleset(
origin=NAME,
rules=[
Rule(permission="getAccessibleAtlassianResources", pattern="*", action="allow"),
Rule(permission="getVisibleJiraProjects", pattern="*", action="allow"),
Rule(permission="searchJiraIssuesUsingJql", pattern="*", action="allow"),
Rule(permission="getJiraIssue", pattern="*", action="allow"),
Rule(permission="getJiraProjectIssueTypesMetadata", pattern="*", action="allow"),
Rule(permission="getJiraIssueTypeMetaWithFields", pattern="*", action="allow"),
Rule(permission="getTransitionsForJiraIssue", pattern="*", action="allow"),
Rule(permission="lookupJiraAccountId", pattern="*", action="allow"),
Rule(permission="createJiraIssue", pattern="*", action="ask"),
Rule(permission="editJiraIssue", pattern="*", action="ask"),
Rule(permission="transitionJiraIssue", pattern="*", action="ask"),
],
)
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}

View file

@ -1,27 +1,22 @@
"""`linear` route: ``SubAgent`` spec for deepagents."""
"""``linear`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools come exclusively from MCP. The connector's own approval ruleset is
declared in :data:`tools.index.RULESET`; the orchestrator layers it into
a per-subagent :class:`PermissionMiddleware`.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "linear"
from .tools.index import NAME, RULESET
def build_subagent(
@ -29,26 +24,20 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles linear tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
description = (
read_md_file(__package__, "description").strip()
or "Handles linear tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
tools=list(mcp_tools or []),
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -1,14 +1,31 @@
"""``linear`` permission ruleset (rules over MCP tool names)."""
from __future__ import annotations
from typing import Any
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
NAME = "linear"
RULESET = Ruleset(
origin=NAME,
rules=[
Rule(permission="list_issues", pattern="*", action="allow"),
Rule(permission="get_issue", pattern="*", action="allow"),
Rule(permission="list_my_issues", pattern="*", action="allow"),
Rule(permission="list_issue_statuses", pattern="*", action="allow"),
Rule(permission="list_issue_labels", pattern="*", action="allow"),
Rule(permission="list_comments", pattern="*", action="allow"),
Rule(permission="list_users", pattern="*", action="allow"),
Rule(permission="get_user", pattern="*", action="allow"),
Rule(permission="list_teams", pattern="*", action="allow"),
Rule(permission="get_team", pattern="*", action="allow"),
Rule(permission="list_projects", pattern="*", action="allow"),
Rule(permission="get_project", pattern="*", action="allow"),
Rule(permission="list_project_labels", pattern="*", action="allow"),
Rule(permission="list_cycles", pattern="*", action="allow"),
Rule(permission="list_documents", pattern="*", action="allow"),
Rule(permission="get_document", pattern="*", action="allow"),
Rule(permission="search_documentation", pattern="*", action="allow"),
Rule(permission="save_issue", pattern="*", action="ask"),
],
)
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}

View file

@ -1,27 +1,22 @@
"""`luma` route: ``SubAgent`` spec for deepagents."""
"""``luma`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "luma"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles luma tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles luma tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -5,7 +5,9 @@ import httpx
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers

View file

@ -1,32 +1,36 @@
"""``luma`` native tools and (empty) permission ruleset.
Tools self-gate via :func:`request_approval` in their bodies.
"""
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .create_event import create_create_luma_event_tool
from .list_events import create_list_luma_events_tool
from .read_event import create_read_luma_event_tool
NAME = "luma"
RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"user_id": d["user_id"],
}
list_ev = create_list_luma_events_tool(**common)
read_ev = create_read_luma_event_tool(**common)
create = create_create_luma_event_tool(**common)
return {
"allow": [
{"name": getattr(list_ev, "name", "") or "", "tool": list_ev},
{"name": getattr(read_ev, "name", "") or "", "tool": read_ev},
],
"ask": [{"name": getattr(create, "name", "") or "", "tool": create}],
}
return [
create_list_luma_events_tool(**common),
create_read_luma_event_tool(**common),
create_create_luma_event_tool(**common),
]

View file

@ -1,27 +1,22 @@
"""`notion` route: ``SubAgent`` spec for deepagents."""
"""``notion`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
from __future__ import annotations
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import read_md_file
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import pack_subagent
from .tools.index import load_tools
NAME = "notion"
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
@ -29,26 +24,21 @@ def build_subagent(
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles notion tasks for this workspace."
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles notion tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

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