mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 04:17:56 +00:00
Pre-fetch prompt tools from the agent's toolset, not the caller's
Tool pre-fetch ran the caller's own active tools, so a teammate chatting with a shared agent had the owner's prompt fill in from their tools, and even the owner got every active tool rather than the agent's. It now uses the toolset the run gets: the agent's tools as its owner or sponsor, or the caller's tools and defaults outside an agent. Pre-fetch asks nobody, so on someone else's tool it skips approval-gated actions and anything on a connected account.
This commit is contained in:
1 parent
5b85eac858
commit
454557c3b0
3 files changed
+137
-25
No files matched your search
@@ -10,7 +10,6 @@ from flask import after_this_request
|
||||
|
||||
from docsgpt import tracing
|
||||
from docsgpt.agents.agent_creator import AgentCreator
|
||||
from docsgpt.agents.default_tools import synthesized_default_tools
|
||||
from docsgpt.api.answer.services.compression import CompressionOrchestrator
|
||||
from docsgpt.api.answer.services.compression.token_counter import TokenCounter
|
||||
from docsgpt.api.answer.services.compression.types import is_compression_summary_row
|
||||
@@ -38,8 +37,6 @@ from docsgpt.storage.db.repositories.attachments import AttachmentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.api.user.team_sharing import can_access
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
from docsgpt.storage.db.source_config import SourceConfig
|
||||
@@ -1376,7 +1373,16 @@ class StreamProcessor:
|
||||
return None, None
|
||||
|
||||
def pre_fetch_tools(self) -> Optional[Dict[str, Any]]:
|
||||
"""Pre-fetch tool data for template rendering before agent creation"""
|
||||
"""Pre-fetch tool data for template rendering before agent creation.
|
||||
|
||||
Runs the actions the prompt template names on the toolset the agent
|
||||
run gets, so a teammate or public-link user renders the owner's
|
||||
prompt with the owner's tools, never their own.
|
||||
|
||||
Returns:
|
||||
Action results keyed by tool name and tool id, or None when
|
||||
nothing was fetched.
|
||||
"""
|
||||
if not settings.ENABLE_TOOL_PREFETCH:
|
||||
logger.info(
|
||||
"Tool pre-fetching disabled globally via ENABLE_TOOL_PREFETCH setting"
|
||||
@@ -1392,17 +1398,14 @@ class StreamProcessor:
|
||||
|
||||
try:
|
||||
user_id = self.initial_user_id or "local"
|
||||
agentless = self.agent_id is None
|
||||
with db_readonly() as conn:
|
||||
user_tools = UserToolsRepository(conn).list_active_for_user(user_id)
|
||||
user_doc = (
|
||||
UsersRepository(conn).get(user_id) if agentless else None
|
||||
)
|
||||
|
||||
default_docs = (
|
||||
synthesized_default_tools(user_doc) if agentless else []
|
||||
)
|
||||
tool_docs = list(user_tools) + default_docs
|
||||
# The same toolset the run gets: an agent's own tools (resolved as
|
||||
# its owner, or the editor who attached them), else the caller's
|
||||
# tools plus defaults. Explicit rows first, so they claim names.
|
||||
run_tools = [
|
||||
tool for tool in self._run_tool_executor().get_tools().values()
|
||||
if isinstance(tool, dict) and not tool.get("client_side")
|
||||
]
|
||||
tool_docs = sorted(run_tools, key=lambda tool: bool(tool.get("default")))
|
||||
if not tool_docs:
|
||||
return None
|
||||
|
||||
@@ -1430,6 +1433,14 @@ class StreamProcessor:
|
||||
continue
|
||||
required_actions = None
|
||||
|
||||
owner = tool_doc.get("user_id")
|
||||
if owner and owner != user_id:
|
||||
# Someone else's tool: pre-fetch asks nobody, so only
|
||||
# what the run would do without asking.
|
||||
required_actions = self._unasked_actions(tool_doc, required_actions)
|
||||
if not required_actions:
|
||||
continue
|
||||
|
||||
tool_data = self._fetch_tool_data(tool_doc, required_actions)
|
||||
if tool_data:
|
||||
# Explicit rows claim the name key; a default tool takes
|
||||
@@ -1446,6 +1457,57 @@ class StreamProcessor:
|
||||
logger.warning(f"Failed to pre-fetch tools: {type(e).__name__}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _unasked_actions(
|
||||
tool_doc: Dict[str, Any], required_actions: Optional[Set[Optional[str]]]
|
||||
) -> Set[Optional[str]]:
|
||||
"""The required actions of someone else's tool that run without asking.
|
||||
|
||||
A tool on someone else's connected account runs on their account or
|
||||
needs the caller's own connection, and an approval-gated action waits
|
||||
for a person; pre-fetch has neither, so both are left out.
|
||||
|
||||
Args:
|
||||
tool_doc: The tool row, owned by someone other than the caller.
|
||||
required_actions: Action names the template needs; None, or a set
|
||||
holding None, means all of them.
|
||||
|
||||
Returns:
|
||||
The action names to run, empty when there are none.
|
||||
"""
|
||||
if tool_doc.get("connection_id"):
|
||||
return set()
|
||||
if tool_doc.get("name") == "api_tool":
|
||||
actions = [
|
||||
{"name": name, **(action or {})}
|
||||
for name, action in ((tool_doc.get("config") or {}).get("actions") or {}).items()
|
||||
]
|
||||
else:
|
||||
actions = tool_doc.get("actions") or []
|
||||
unasked = {
|
||||
action.get("name") for action in actions
|
||||
if action.get("name") and action.get("active", True) and not action.get("require_approval")
|
||||
}
|
||||
if required_actions is None or None in required_actions:
|
||||
return unasked
|
||||
return {name for name in required_actions if name in unasked}
|
||||
|
||||
def _run_tool_executor(self):
|
||||
"""A ``ToolExecutor`` resolving the toolset this turn's agent run gets.
|
||||
|
||||
Returns:
|
||||
ToolExecutor: Built with the run's key, user and agent.
|
||||
"""
|
||||
from docsgpt.agents.tool_executor import ToolExecutor
|
||||
|
||||
user = self.decoded_token.get("sub") if self.decoded_token else None
|
||||
return ToolExecutor(
|
||||
user_api_key=self.agent_config.get("user_api_key"),
|
||||
user=user,
|
||||
decoded_token=self.decoded_token,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
|
||||
def _enabled_tool_names(self) -> Optional[set]:
|
||||
"""Resolve the tool names enabled for this turn, for ``tools.enabled`` gating.
|
||||
|
||||
@@ -1455,15 +1517,7 @@ class StreamProcessor:
|
||||
(keeps the section) rather than hiding guidance when resolution breaks.
|
||||
"""
|
||||
try:
|
||||
from docsgpt.agents.tool_executor import ToolExecutor
|
||||
|
||||
user = self.decoded_token.get("sub") if self.decoded_token else None
|
||||
tool_executor = ToolExecutor(
|
||||
user_api_key=self.agent_config.get("user_api_key"),
|
||||
user=user,
|
||||
decoded_token=self.decoded_token,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
tool_executor = self._run_tool_executor()
|
||||
client_tools = self.data.get("client_tools")
|
||||
if client_tools:
|
||||
tool_executor.client_tools = client_tools
|
||||
|
||||
@@ -22,7 +22,7 @@ def _patch_db(conn):
|
||||
"docsgpt.api.answer.services.stream_processor.db_readonly", _yield
|
||||
), patch(
|
||||
"docsgpt.api.answer.services.stream_processor.db_session", _yield
|
||||
):
|
||||
), patch("docsgpt.agents.tool_executor.db_readonly", _yield):
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -386,6 +386,64 @@ class TestRunTime:
|
||||
assert _authorized_source_ids(pg_conn, agent, [source]) == []
|
||||
|
||||
|
||||
class TestToolPrefetch:
|
||||
"""Prompt tool pre-fetch runs the agent's tools, as the agent run resolves them."""
|
||||
|
||||
def _prefetched(self, pg_conn, agent_id, caller, required):
|
||||
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
||||
from docsgpt.core.settings import settings
|
||||
|
||||
agent = _row(pg_conn, agent_id)
|
||||
processor = StreamProcessor({"agent_id": agent_id}, {"sub": caller})
|
||||
processor.agent_id = agent_id
|
||||
processor.agent_config = {"user_api_key": agent["key"]}
|
||||
processor._required_tool_actions = required
|
||||
fetched = {}
|
||||
|
||||
def _fake_fetch(tool_doc, required_actions):
|
||||
fetched[str(tool_doc["id"])] = required_actions
|
||||
return {"ok": True}
|
||||
|
||||
with _patch_db(pg_conn), patch.object(settings, "ENABLE_TOOL_PREFETCH", True), \
|
||||
patch.object(processor, "_fetch_tool_data", _fake_fetch):
|
||||
processor.pre_fetch_tools()
|
||||
return fetched
|
||||
|
||||
def _tool(self, pg_conn, user, name, actions, **extra):
|
||||
return str(UserToolsRepository(pg_conn).create(user, name, actions=actions, **extra)["id"])
|
||||
|
||||
def test_prefetch_runs_the_agents_tools_not_the_callers(self, pg_conn):
|
||||
agent_id, _ = _agent(pg_conn)
|
||||
owner_tool = self._tool(pg_conn, OWNER, "read_webpage", [{"name": "fetch", "active": True}])
|
||||
self._tool(pg_conn, VIEWER, "cryptoprice", [{"name": "price", "active": True}])
|
||||
AgentsRepository(pg_conn).update_by_id(agent_id, {"tools": [owner_tool]})
|
||||
required = {"read_webpage": {None}, "cryptoprice": {None}}
|
||||
assert self._prefetched(pg_conn, agent_id, OWNER, required) == {owner_tool: {None}}
|
||||
assert list(self._prefetched(pg_conn, agent_id, VIEWER, required)) == [owner_tool]
|
||||
|
||||
def test_someone_elses_tool_prefetches_only_what_runs_unasked(self, pg_conn):
|
||||
from sqlalchemy import text
|
||||
|
||||
agent_id, _ = _agent(pg_conn)
|
||||
api = self._tool(pg_conn, OWNER, "read_webpage", [
|
||||
{"name": "fetch", "active": True},
|
||||
{"name": "post", "active": True, "require_approval": True},
|
||||
])
|
||||
connection = str(pg_conn.execute(text(
|
||||
"INSERT INTO connector_sessions (user_id, provider, connector_key, auth_kind, status) "
|
||||
"VALUES (:u, 'telegram', 'telegram', 'api_key', 'connected') RETURNING id"
|
||||
), {"u": OWNER}).scalar())
|
||||
connected = self._tool(pg_conn, OWNER, "telegram", [{"name": "telegram_get_updates", "active": True}],
|
||||
connection_id=connection)
|
||||
AgentsRepository(pg_conn).update_by_id(agent_id, {"tools": [api, connected]})
|
||||
required = {"read_webpage": {None}, "telegram": {None}}
|
||||
|
||||
assert self._prefetched(pg_conn, agent_id, OWNER, required) == {api: {None}, connected: {None}}
|
||||
# Nobody is asked during pre-fetch: a teammate gets no approval-gated
|
||||
# action and nothing on the owner's connected account.
|
||||
assert self._prefetched(pg_conn, agent_id, VIEWER, required) == {api: {"fetch"}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user