mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(skills): add guarded evolution host APIs * docs(agents): avoid growing saturated guidance chains * fix(skills): address mutation host review findings * fix(skills): order name fence before projection lock * fix(skills): preserve composite toggle lock order * fix(skills): address catalog and pagination review feedback * docs: keep subagent guidance within merged instruction budget --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""Recovery is an authenticated host operation, never a plugin route."""
|
|
|
|
from dataclasses import dataclass
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
|
|
from deerflow.skills.mutations.workers import MutationWorkers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("role,source,status", [("user", None, 403), ("admin", "pat", 403), ("admin", None, 200)])
|
|
async def test_admin_recovery_auth_and_off_loop(role, source, status):
|
|
import threading
|
|
|
|
from app.gateway.routers.skill_mutations import router
|
|
|
|
loop_thread = threading.get_ident()
|
|
calls = []
|
|
|
|
@dataclass
|
|
class Result:
|
|
operation_id: str
|
|
publication: str = "APPLIED"
|
|
|
|
class Recovery:
|
|
def list_operations(self, *, limit, after_id):
|
|
assert threading.get_ident() != loop_thread
|
|
assert limit == 51 and after_id is None
|
|
calls.append("list")
|
|
return (Result("a" * 32),)
|
|
|
|
def get_operation(self, operation_id):
|
|
assert threading.get_ident() != loop_thread
|
|
calls.append("query")
|
|
return Result(operation_id)
|
|
|
|
def recover_operation(self, operation_id):
|
|
assert threading.get_ident() != loop_thread
|
|
calls.append("recover")
|
|
return Result(operation_id)
|
|
|
|
app = FastAPI()
|
|
workers = MutationWorkers()
|
|
app.state.skill_mutation_host = SimpleNamespace(workers=workers, recovery=Recovery())
|
|
|
|
@app.middleware("http")
|
|
async def identity(request: Request, call_next):
|
|
request.state.user = SimpleNamespace(id="admin", system_role=role)
|
|
request.state.auth_source = source
|
|
return await call_next(request)
|
|
|
|
app.include_router(router)
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
base = "/api/skill-mutations/operations/" + "a" * 32
|
|
listed = await client.get("/api/skill-mutations/operations")
|
|
assert listed.status_code == status
|
|
if status == 200:
|
|
assert listed.json()["items"][0]["operation_id"] == "a" * 32
|
|
assert (await client.get(base)).status_code == status
|
|
assert (await client.post(base + "/recover")).status_code == status
|
|
assert calls == (["list", "query", "recover"] if status == 200 else [])
|
|
finally:
|
|
await workers.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_errors_do_not_return_database_or_candidate_content():
|
|
from deerflow_extension_api import HostCapabilityError
|
|
|
|
from app.gateway.routers.skill_mutations import router
|
|
|
|
class Recovery:
|
|
def get_operation(self, operation_id):
|
|
raise HostCapabilityError("NOT_FOUND_OR_FORBIDDEN", "candidate content must not leak")
|
|
|
|
app = FastAPI()
|
|
workers = MutationWorkers()
|
|
app.state.skill_mutation_host = SimpleNamespace(workers=workers, recovery=Recovery())
|
|
|
|
@app.middleware("http")
|
|
async def identity(request: Request, call_next):
|
|
request.state.user = SimpleNamespace(id="admin", system_role="admin")
|
|
return await call_next(request)
|
|
|
|
app.include_router(router)
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
response = await client.get("/api/skill-mutations/operations/" + "a" * 32)
|
|
assert response.status_code == 404
|
|
assert "candidate content" not in response.text
|
|
finally:
|
|
await workers.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("count,limit", [(0, 2), (1, 2), (2, 2), (3, 2), (3, 1)])
|
|
async def test_operation_pagination_terminates_with_null_cursor(count, limit):
|
|
from app.gateway.routers.skill_mutations import router
|
|
|
|
@dataclass
|
|
class Result:
|
|
operation_id: str
|
|
|
|
operations = [Result(f"{number:032x}") for number in range(1, count + 1)]
|
|
|
|
class Recovery:
|
|
def list_operations(self, *, limit, after_id):
|
|
return tuple(row for row in operations if after_id is None or row.operation_id > after_id)[:limit]
|
|
|
|
app = FastAPI()
|
|
workers = MutationWorkers()
|
|
app.state.skill_mutation_host = SimpleNamespace(workers=workers, recovery=Recovery())
|
|
|
|
@app.middleware("http")
|
|
async def identity(request: Request, call_next):
|
|
request.state.user = SimpleNamespace(id="admin", system_role="admin")
|
|
return await call_next(request)
|
|
|
|
app.include_router(router)
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
params = {"limit": limit}
|
|
seen = []
|
|
for _ in range(count + 1):
|
|
response = await client.get("/api/skill-mutations/operations", params=params)
|
|
assert response.status_code == 200
|
|
page = response.json()
|
|
seen.extend(row["operation_id"] for row in page["items"])
|
|
assert page["has_more"] == (page["next_cursor"] is not None)
|
|
if page["next_cursor"] is None:
|
|
break
|
|
params["after_id"] = page["next_cursor"]
|
|
else:
|
|
pytest.fail("Pagination failed to terminate")
|
|
assert seen == [row.operation_id for row in operations]
|
|
# A stale end cursor must not echo itself back forever.
|
|
empty = await client.get("/api/skill-mutations/operations", params={"after_id": "f" * 32, "limit": limit})
|
|
assert empty.json() == {"items": [], "has_more": False, "next_cursor": None}
|
|
finally:
|
|
await workers.close()
|