mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge branch 'dev' of https://github.com/MODSetter/SurfSense into dev
This commit is contained in:
commit
dc88ce0277
193 changed files with 6934 additions and 2192 deletions
|
|
@ -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``)."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
def check_cloud_write_namespace(
|
||||
mw: "SurfSenseFilesystemMiddleware",
|
||||
mw: SurfSenseFilesystemMiddleware,
|
||||
path: str,
|
||||
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||
) -> str | None:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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``)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
"""Route-local tool policy for the ``knowledge_base`` subagent."""
|
||||
"""Route-local tool permissions for the ``knowledge_base`` subagent."""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue