mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 15:50:35 +00:00
* fix(gateway): read the run and authorization config off the event loop get_app_config() stats and hashes config.yaml and extensions_config.json on every call and reparses them after an edit. Run admission read it inline on the loop two to four times per start (get_run_context, build_run_config, the model allowlist, the approval switch, conversation references), and the model list, model details, skill list, suggestions and sandbox gates read the authorization section inline once each. start_run now builds its run context in a worker and reuses that context's app_config for the rest of the start, so one run start sees one snapshot. Scheduled dispatch resolves its recursion limit in a worker, run inputs take only the AgentRuns capability, and the route-scoped authorization helpers run through asyncio.to_thread, as /api/models/resolve already did. * docs(changelog): reference #8137 for the off-loop run and authorization config reads * fix(gateway): validate the tool approval mode against the run's config snapshot The approval-mode check added on main read get_app_config() inline in start_run, so every run start revalidated config.yaml on the event loop again. It now uses the snapshot the run context was built with, like the model allowlist and the approval switch. * test(gateway): seed the skill catalog the authorization anchor lists The skill listing anchor relied on the checkout's skills/public tree containing academic-paper-review. It now writes one allowed and one hidden skill under its own skills.path, so it passes without the repo catalog and still fails if the policy is not applied.
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""Goal writes reject unusable objectives and counters before admission."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from _router_auth_helpers import make_authed_test_app
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.types import Overwrite
|
|
|
|
from app.gateway import services
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.routers import runs, thread_runs, threads
|
|
from deerflow.agents.thread_state import get_thread_state_schema
|
|
from deerflow.utils.goal_objective import MAX_GOAL_OBJECTIVE_CHARS
|
|
|
|
_JSON_OVERWRITE_GOAL = {
|
|
"objective": "outer objective",
|
|
"type": "__overwrite__",
|
|
"value": {"status": "active", "continuation_count": "many"},
|
|
}
|
|
_KEYED_OVERWRITE_GOAL = {"objective": "outer objective", "__overwrite__": {"status": "active", "continuation_count": "many"}}
|
|
|
|
|
|
@pytest.mark.parametrize("boundary", [services.normalize_input, services.strip_server_owned_state_metadata])
|
|
@pytest.mark.parametrize(
|
|
"goal",
|
|
[
|
|
{"status": "active"},
|
|
{"objective": None},
|
|
{"objective": 42},
|
|
{"objective": ""},
|
|
{"objective": " \n\t "},
|
|
{"objective": "x" * (MAX_GOAL_OBJECTIVE_CHARS + 1)},
|
|
"finish",
|
|
[],
|
|
],
|
|
)
|
|
def test_external_goal_requires_a_usable_objective(boundary, goal):
|
|
with pytest.raises(HTTPException) as rejected:
|
|
boundary({"goal": goal})
|
|
assert rejected.value.status_code == 400
|
|
assert "goal" in rejected.value.detail.lower()
|
|
assert "x" * 100 not in rejected.value.detail
|
|
|
|
|
|
@pytest.mark.parametrize("boundary", [services.normalize_input, services.strip_server_owned_state_metadata])
|
|
@pytest.mark.parametrize("field", ["continuation_count", "max_continuations", "no_progress_count", "max_no_progress_continuations"])
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
pytest.param("many", id="text"),
|
|
pytest.param("2", id="numeric-string"),
|
|
pytest.param(-1, id="negative"),
|
|
pytest.param(1.5, id="float"),
|
|
pytest.param(True, id="boolean"),
|
|
pytest.param(None, id="null"),
|
|
],
|
|
)
|
|
def test_external_goal_rejects_invalid_present_counters(boundary, field, value):
|
|
source = {"goal": {"objective": "finish", field: value}}
|
|
with pytest.raises(HTTPException) as rejected:
|
|
boundary(source)
|
|
assert rejected.value.status_code == 400
|
|
assert f"goal.{field}" in rejected.value.detail
|
|
assert "many" not in rejected.value.detail
|
|
assert source == {"goal": {"objective": "finish", field: value}}
|
|
|
|
|
|
@pytest.mark.parametrize("boundary", [services.normalize_input, services.strip_server_owned_state_metadata])
|
|
@pytest.mark.parametrize(
|
|
"goal",
|
|
[
|
|
pytest.param(_JSON_OVERWRITE_GOAL, id="json-overwrite"),
|
|
pytest.param(_KEYED_OVERWRITE_GOAL, id="reserved-key"),
|
|
pytest.param({"objective": "finish", "type": "__overwrite__"}, id="marker-without-value"),
|
|
pytest.param({"objective": "finish", "type": "__overwrite__", "value": None}, id="overwrite-clear"),
|
|
],
|
|
)
|
|
def test_external_goal_rejects_reducer_control_markers(boundary, goal):
|
|
with pytest.raises(HTTPException) as rejected:
|
|
boundary({"goal": goal})
|
|
assert rejected.value.status_code == 400
|
|
assert "goal" in rejected.value.detail
|
|
assert "outer objective" not in rejected.value.detail
|
|
assert "many" not in rejected.value.detail
|
|
|
|
|
|
@pytest.mark.parametrize("boundary", [services.normalize_input, services.strip_server_owned_state_metadata])
|
|
def test_external_goal_keeps_ordinary_type_and_value_fields(boundary):
|
|
goal = {"objective": "finish", "continuation_count": 7, "max_continuations": 8, "type": "business", "value": {"continuation_count": "many"}}
|
|
assert boundary({"goal": goal})["goal"] == goal
|
|
|
|
|
|
def test_trusted_internal_goal_input_preserves_typed_overwrite():
|
|
overwrite = Overwrite({"objective": "finish", "continuation_count": 7, "max_continuations": 8})
|
|
assert services.normalize_input({"goal": overwrite}, trusted_internal=True)["goal"] is overwrite
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
@pytest.mark.parametrize("initial_goal", [None, {"objective": "previous", "status": "active", "continuation_count": 7, "max_continuations": 8}], ids=["cleared", "active"])
|
|
async def test_run_input_rejects_overwrite_before_mutating_existing_goal(mode, initial_goal):
|
|
checkpointer = InMemorySaver()
|
|
builder = StateGraph(get_thread_state_schema(mode))
|
|
builder.add_node("finish", lambda _state: {})
|
|
builder.set_entry_point("finish")
|
|
builder.set_finish_point("finish")
|
|
graph = builder.compile(checkpointer=checkpointer)
|
|
config = {"configurable": {"thread_id": "goal-overwrite-input", "checkpoint_ns": ""}}
|
|
# An initialized goal channel is required to exercise LangGraph's sentinel
|
|
# unpacking; the first write into an unset channel keeps the outer mapping.
|
|
await graph.ainvoke({"goal": initial_goal}, config)
|
|
before = await checkpointer.aget_tuple(config)
|
|
assert before is not None
|
|
|
|
with pytest.raises(HTTPException) as rejected:
|
|
await graph.ainvoke(services.normalize_input({"goal": _JSON_OVERWRITE_GOAL}), config)
|
|
assert rejected.value.status_code == 400
|
|
after = await checkpointer.aget_tuple(config)
|
|
assert after is not None
|
|
assert after.config == before.config
|
|
assert after.checkpoint == before.checkpoint
|
|
assert (await graph.aget_state(config)).values["goal"] == initial_goal
|
|
|
|
|
|
@pytest.mark.parametrize("boundary", [services.normalize_input, services.strip_server_owned_state_metadata])
|
|
@pytest.mark.parametrize(
|
|
"counters",
|
|
[
|
|
{"continuation_count": 0, "max_continuations": 0, "no_progress_count": 0, "max_no_progress_continuations": 0},
|
|
{"continuation_count": 7, "max_continuations": 8, "no_progress_count": 1, "max_no_progress_continuations": 2},
|
|
{"continuation_count": 9, "max_continuations": 8, "no_progress_count": 3, "max_no_progress_continuations": 2},
|
|
],
|
|
ids=["zero", "remaining-budget", "used-budget"],
|
|
)
|
|
def test_external_goal_preserves_valid_counters_without_resetting_budget(boundary, counters):
|
|
goal = {"objective": "finish", **counters}
|
|
assert boundary({"goal": goal})["goal"] == goal
|
|
|
|
|
|
def test_trusted_internal_goal_input_keeps_existing_restore_protocol():
|
|
source = {"goal": {"objective": "finish", "continuation_count": "many"}}
|
|
assert services.normalize_input(source, trusted_internal=True) == source
|
|
|
|
|
|
@pytest.mark.parametrize("boundary", [services.normalize_input, services.strip_server_owned_state_metadata])
|
|
def test_valid_sparse_goals_are_normalized_without_building_fresh_state(boundary):
|
|
sparse = {"objective": " finish\n the report "}
|
|
source = {"goal": sparse}
|
|
admitted = boundary(source)
|
|
|
|
assert admitted["goal"] == {"objective": "finish the report"}
|
|
assert source == {"goal": {"objective": " finish\n the report "}}
|
|
assert set(admitted["goal"]) == {"objective"}
|
|
assert boundary({"goal": None}) == {"goal": None}
|
|
assert boundary({"title": "unrelated update"}) == {"title": "unrelated update"}
|
|
|
|
|
|
def test_goal_replacement_preserves_supplied_counters_and_clears_previous_outcome():
|
|
goal = {"objective": "finish", "status": "active", "continuation_count": 7, "max_continuations": 8}
|
|
assert services.normalize_input({"goal": goal}) == {"goal": goal, "goal_outcome": None}
|
|
assert services.normalize_input({"goal": None}) == {"goal": None}
|
|
assert services.normalize_input({"goal": goal}, trusted_internal=True) == {"goal": goal}
|
|
|
|
|
|
class _StateRunManager:
|
|
def __init__(self):
|
|
self.reservations = 0
|
|
|
|
@asynccontextmanager
|
|
async def reserve_thread_operation(self, *_args, **_kwargs):
|
|
self.reservations += 1
|
|
yield
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
@pytest.mark.parametrize(
|
|
"invalid_goal",
|
|
[
|
|
pytest.param({"status": "active"}, id="missing-objective"),
|
|
pytest.param({"objective": "finish", "continuation_count": "many"}, id="invalid-count"),
|
|
pytest.param({"objective": "finish", "max_continuations": True}, id="boolean-limit"),
|
|
pytest.param({"objective": "finish", "no_progress_count": -1}, id="negative-count"),
|
|
pytest.param({"objective": "finish", "max_no_progress_continuations": None}, id="null-limit"),
|
|
pytest.param(_JSON_OVERWRITE_GOAL, id="json-overwrite"),
|
|
pytest.param(_KEYED_OVERWRITE_GOAL, id="reserved-key"),
|
|
],
|
|
)
|
|
def test_state_route_rejects_invalid_goal_without_changing_checkpoint(monkeypatch, mode, invalid_goal):
|
|
user = User(email="goal-input@example.com", password_hash="x", id=UUID("00000000-0000-0000-0000-000000000001"))
|
|
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
|
|
app.include_router(threads.router)
|
|
app.state.checkpointer = checkpointer = InMemorySaver()
|
|
app.state.checkpoint_channel_mode = mode
|
|
app.state.run_manager = manager = _StateRunManager()
|
|
config = {"configurable": {"thread_id": "goal-input-http", "checkpoint_ns": ""}}
|
|
|
|
# Keep the real state mutation graph and saver; assistant-schema resolution
|
|
# is unrelated to goal admission, so use the base schema here.
|
|
async def build_accessor(request, *, thread_id, as_node, checkpoint_id=None):
|
|
return services.build_checkpoint_state_mutation_accessor(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id)
|
|
|
|
builder = AsyncMock(side_effect=build_accessor)
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", builder)
|
|
|
|
with TestClient(app) as client:
|
|
valid = client.post("/api/threads/goal-input-http/state", json={"values": {"goal": {"objective": "finish", "status": "active"}}})
|
|
assert valid.status_code == 200, valid.text
|
|
assert valid.json()["values"]["goal"] == {"objective": "finish", "status": "active"}
|
|
before = asyncio.run(checkpointer.aget_tuple(config))
|
|
assert before is not None
|
|
builder.reset_mock()
|
|
reservations = manager.reservations
|
|
|
|
rejected = client.post("/api/threads/goal-input-http/state", json={"values": {"goal": invalid_goal, "title": "must not write"}})
|
|
assert rejected.status_code == 400, rejected.text
|
|
builder.assert_not_awaited()
|
|
assert manager.reservations == reservations
|
|
after = asyncio.run(checkpointer.aget_tuple(config))
|
|
assert after is not None
|
|
assert after.config == before.config
|
|
assert after.checkpoint == before.checkpoint
|
|
|
|
cleared = client.post("/api/threads/goal-input-http/state", json={"values": {"goal": None}})
|
|
assert cleared.status_code == 200, cleared.text
|
|
assert cleared.json()["values"].get("goal") is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
"/api/threads/goal-input-http/runs",
|
|
"/api/threads/goal-input-http/runs/stream",
|
|
"/api/threads/goal-input-http/runs/wait",
|
|
"/api/runs/stream",
|
|
"/api/runs/wait",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"invalid_goal",
|
|
[
|
|
pytest.param({"status": "active"}, id="missing-objective"),
|
|
pytest.param({"objective": "finish", "continuation_count": "many"}, id="invalid-counter"),
|
|
pytest.param(_JSON_OVERWRITE_GOAL, id="json-overwrite"),
|
|
pytest.param(_KEYED_OVERWRITE_GOAL, id="reserved-key"),
|
|
],
|
|
)
|
|
def test_run_routes_reject_invalid_goal_before_admission(monkeypatch, path, invalid_goal):
|
|
app = make_authed_test_app()
|
|
app.include_router(runs.router)
|
|
app.include_router(thread_runs.router)
|
|
app.state.stream_bridge = SimpleNamespace()
|
|
app.state.run_manager = SimpleNamespace(create_or_reject=AsyncMock())
|
|
monkeypatch.setattr(services, "get_run_context", lambda _request: SimpleNamespace(thread_store=app.state.thread_store, app_config=None))
|
|
factory = Mock(side_effect=AssertionError("invalid goal reached graph assembly"))
|
|
monkeypatch.setattr(services, "resolve_agent_factory", factory)
|
|
worker = AsyncMock()
|
|
monkeypatch.setattr(services, "run_agent", worker)
|
|
|
|
with TestClient(app, raise_server_exceptions=False) as client:
|
|
rejected = client.post(path, json={"input": {"goal": invalid_goal}})
|
|
|
|
assert rejected.status_code == 400, rejected.text
|
|
factory.assert_not_called()
|
|
app.state.run_manager.create_or_reject.assert_not_awaited()
|
|
worker.assert_not_awaited()
|