Files
DocsGPT/tests/agents/test_tool_executor_headless.py
T
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01:00

206 lines
7.0 KiB
Python

"""Headless mode + tool allowlist enforcement on ToolExecutor.check_pause."""
from __future__ import annotations
from types import SimpleNamespace
from docsgpt.agents.tool_executor import ToolExecutor
def _call(name: str, args: dict | None = None, call_id: str = "c1"):
import json
return SimpleNamespace(
id=call_id,
name=name,
arguments=json.dumps(args or {}),
thought_signature=None,
)
def _executor(*, headless=False, allowlist=None):
ex = ToolExecutor(headless=headless, tool_allowlist=allowlist or [])
ex._name_to_tool = {
"send": ("tool-a", "send"),
"freecall": ("tool-b", "freecall"),
"client_only": ("ct0", "client_only"),
}
return ex
def _tools_dict():
return {
"tool-a": {
"id": "tool-a",
"name": "telegram",
"actions": [
{"name": "send", "require_approval": True},
],
},
"tool-b": {
"id": "tool-b",
"name": "noop",
"actions": [
{"name": "freecall", "require_approval": False},
],
},
"ct0": {
"name": "client_only",
"client_side": True,
"actions": [
{"name": "client_only"},
],
},
}
class TestHeadlessApproval:
def test_denied_when_not_in_allowlist(self):
ex = _executor(headless=True, allowlist=[])
result = ex.check_pause(_tools_dict(), _call("send"), "MockLLM")
assert result is not None
assert result["pause_type"] == "headless_denied"
assert result["error_type"] == "tool_not_allowed"
def test_allowed_when_in_allowlist(self):
ex = _executor(headless=True, allowlist=["tool-a"])
assert ex.check_pause(_tools_dict(), _call("send"), "MockLLM") is None
def test_non_approval_tool_runs_freely(self):
ex = _executor(headless=True, allowlist=[])
assert ex.check_pause(_tools_dict(), _call("freecall"), "MockLLM") is None
class TestHeadlessClientSide:
def test_client_side_always_denied_in_headless(self):
# Client-side ignores the allowlist; no headless answer is possible.
ex = _executor(headless=True, allowlist=["ct0"])
result = ex.check_pause(_tools_dict(), _call("client_only"), "MockLLM")
assert result is not None
assert result["pause_type"] == "headless_denied"
class TestNormalModeUnchanged:
def test_approval_still_pauses_without_headless(self):
ex = _executor(headless=False)
result = ex.check_pause(_tools_dict(), _call("send"), "MockLLM")
assert result["pause_type"] == "awaiting_approval"
def test_client_side_still_pauses_without_headless(self):
ex = _executor(headless=False)
result = ex.check_pause(_tools_dict(), _call("client_only"), "MockLLM")
assert result["pause_type"] == "requires_client_execution"
# ---------------------------------------------------------------------------
# Scheduler exclusion in headless runs — chat-only tool must not appear in
# the toolset when a scheduled / webhook LLM runs, else it could re-schedule.
# ---------------------------------------------------------------------------
class TestHeadlessSchedulerExclusion:
def test_synthesized_default_tools_drops_scheduler_in_headless(self):
from docsgpt.agents.default_tools import (
loaded_default_tools,
synthesized_default_tools,
)
# Sanity: scheduler is on for normal chats…
names_chat = {r["name"] for r in synthesized_default_tools(None)}
if "scheduler" in loaded_default_tools():
assert "scheduler" in names_chat
# …and silently absent for headless runs.
names_headless = {
r["name"]
for r in synthesized_default_tools(None, headless=True)
}
assert "scheduler" not in names_headless
def test_get_user_tools_filters_scheduler_when_headless(
self, monkeypatch,
):
from docsgpt.agents import tool_executor as te_module
from docsgpt.agents.default_tools import (
default_tool_id,
loaded_default_tools,
)
if "scheduler" not in loaded_default_tools():
import pytest as _pytest # local alias to keep top-of-module noise low
_pytest.skip("scheduler not loaded in this env")
# Stub the DB layer: no explicit user_tools so the synthesized
# defaults are the only ``scheduler`` source — that path is what
# this test pins.
from contextlib import contextmanager
@contextmanager
def _fake_readonly():
yield object()
monkeypatch.setattr(te_module, "db_readonly", _fake_readonly)
monkeypatch.setattr(
te_module, "UserToolsRepository",
lambda _c: type("R", (), {
"list_active_for_user": lambda _self, _u: [],
})(),
)
monkeypatch.setattr(
te_module, "UsersRepository",
lambda _c: type("R", (), {
"get": lambda _self, _u: None,
})(),
)
sched_id = default_tool_id("scheduler")
ex_chat = te_module.ToolExecutor(headless=False)
tools_chat = ex_chat._get_user_tools("u-test")
assert sched_id in tools_chat
ex_headless = te_module.ToolExecutor(headless=True)
tools_headless = ex_headless._get_user_tools("u-test")
assert sched_id not in tools_headless
def test_get_tools_by_api_key_drops_scheduler_when_headless(
self, monkeypatch,
):
"""An agent-bound headless run (e.g. webhook) skips scheduler even if
the author added the synthetic id to ``agents.tools``."""
from docsgpt.agents import tool_executor as te_module
from docsgpt.agents.default_tools import default_tool_id
sched_id = default_tool_id("scheduler")
from contextlib import contextmanager
@contextmanager
def _fake_readonly():
yield object()
class _AgentsRepo:
def __init__(self, _conn):
pass
def find_by_key(self, _k):
return {"user_id": "u1", "tools": [sched_id]}
class _UTRepo:
def __init__(self, _conn):
pass
def get_any(self, _t, _u):
return None
monkeypatch.setattr(te_module, "db_readonly", _fake_readonly)
monkeypatch.setattr(te_module, "AgentsRepository", _AgentsRepo)
monkeypatch.setattr(te_module, "UserToolsRepository", _UTRepo)
ex_normal = te_module.ToolExecutor(
user_api_key="k", headless=False, agent_id="a",
)
tools_normal = ex_normal._get_tools_by_api_key("k")
assert sched_id in tools_normal
ex_headless = te_module.ToolExecutor(
user_api_key="k", headless=True, agent_id="a",
)
tools_headless = ex_headless._get_tools_by_api_key("k")
assert sched_id not in tools_headless